From 5a6efcf183cce7309a35cddcbdb5349f78de45a8 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Wed, 6 May 2026 19:44:04 -0300 Subject: [PATCH 01/12] feat(cli): add managed worktree workflow --- codex-rs/Cargo.lock | 16 + codex-rs/Cargo.toml | 2 + codex-rs/cli/Cargo.toml | 1 + codex-rs/cli/src/main.rs | 388 +++++++++++ codex-rs/exec/Cargo.toml | 1 + codex-rs/exec/src/lib.rs | 20 + codex-rs/tui/Cargo.toml | 1 + codex-rs/tui/src/app.rs | 3 + codex-rs/tui/src/app/startup_prompts.rs | 6 + codex-rs/tui/src/app/thread_routing.rs | 4 + codex-rs/tui/src/app_command.rs | 7 + codex-rs/tui/src/app_server_session.rs | 18 + codex-rs/tui/src/lib.rs | 1 + codex-rs/tui/src/resume_picker.rs | 126 +++- codex-rs/tui/src/worktree_labels.rs | 49 ++ codex-rs/utils/cli/src/lib.rs | 2 + codex-rs/utils/cli/src/shared_options.rs | 40 ++ .../utils/cli/src/worktree_dirty_cli_arg.rs | 11 + codex-rs/worktree/BUILD.bazel | 7 + codex-rs/worktree/Cargo.toml | 19 + codex-rs/worktree/src/dirty.rs | 182 ++++++ codex-rs/worktree/src/git.rs | 57 ++ codex-rs/worktree/src/lib.rs | 105 +++ codex-rs/worktree/src/manager.rs | 604 ++++++++++++++++++ codex-rs/worktree/src/metadata.rs | 157 +++++ codex-rs/worktree/src/paths.rs | 105 +++ codex-rs/worktree/tests/git_backend.rs | 239 +++++++ 27 files changed, 2157 insertions(+), 14 deletions(-) create mode 100644 codex-rs/tui/src/worktree_labels.rs create mode 100644 codex-rs/utils/cli/src/worktree_dirty_cli_arg.rs create mode 100644 codex-rs/worktree/BUILD.bazel create mode 100644 codex-rs/worktree/Cargo.toml create mode 100644 codex-rs/worktree/src/dirty.rs create mode 100644 codex-rs/worktree/src/git.rs create mode 100644 codex-rs/worktree/src/lib.rs create mode 100644 codex-rs/worktree/src/manager.rs create mode 100644 codex-rs/worktree/src/metadata.rs create mode 100644 codex-rs/worktree/src/paths.rs create mode 100644 codex-rs/worktree/tests/git_backend.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index f78f27ffa347..d914890f558e 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2211,6 +2211,7 @@ dependencies = [ "codex-utils-cli", "codex-utils-path", "codex-windows-sandbox", + "codex-worktree", "libc", "owo-colors", "predicates", @@ -2678,6 +2679,7 @@ dependencies = [ "codex-utils-cargo-bin", "codex-utils-cli", "codex-utils-oss", + "codex-worktree", "core_test_support", "libc", "opentelemetry", @@ -3713,6 +3715,7 @@ dependencies = [ "codex-utils-sleep-inhibitor", "codex-utils-string", "codex-windows-sandbox", + "codex-worktree", "color-eyre", "cpal", "crossterm", @@ -4021,6 +4024,19 @@ dependencies = [ "winres", ] +[[package]] +name = "codex-worktree" +version = "0.0.0" +dependencies = [ + "anyhow", + "codex-utils-absolute-path", + "pretty_assertions", + "serde", + "serde_json", + "sha2", + "tempfile", +] + [[package]] name = "color-eyre" version = "0.6.5" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index f1673d3bab76..c95dbb415da3 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -74,6 +74,7 @@ members = [ "otel", "tui", "tools", + "worktree", "v8-poc", "utils/absolute-path", "utils/cargo-bin", @@ -206,6 +207,7 @@ codex-thread-store = { path = "thread-store" } codex-tools = { path = "tools" } codex-tui = { path = "tui" } codex-uds = { path = "uds" } +codex-worktree = { path = "worktree" } codex-utils-absolute-path = { path = "utils/absolute-path" } codex-utils-approval-presets = { path = "utils/approval-presets" } codex-utils-cache = { path = "utils/cache" } diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index b644734f4cb9..89249654ea0c 100644 --- a/codex-rs/cli/Cargo.toml +++ b/codex-rs/cli/Cargo.toml @@ -49,6 +49,7 @@ codex-state = { workspace = true } codex-stdio-to-uds = { workspace = true } codex-terminal-detection = { workspace = true } codex-tui = { workspace = true } +codex-worktree = { workspace = true } codex-utils-absolute-path = { workspace = true } codex-utils-path = { workspace = true } libc = { workspace = true } diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index f3545629c1b9..fa2cc62a52dc 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -34,8 +34,18 @@ use codex_tui::ExitReason; use codex_tui::UpdateAction; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_cli::CliConfigOverrides; +use codex_utils_cli::SharedCliOptions; +use codex_utils_cli::WorktreeDirtyCliArg; +use codex_worktree::DirtyPolicy; +use codex_worktree::WorktreeInfo; +use codex_worktree::WorktreeListQuery; +use codex_worktree::WorktreeRemoveRequest; +use codex_worktree::WorktreeRequest; +use codex_worktree::WorktreeResolution; use owo_colors::OwoColorize; +use std::fs; use std::io::IsTerminal; +use std::path::Path; use std::path::PathBuf; use supports_color::Stream; @@ -160,6 +170,9 @@ enum Subcommand { /// Fork a previous interactive session (picker by default; use --last to fork the most recent). Fork(ForkCommand), + /// Manage Codex-managed Git worktrees. + Worktree(WorktreeCli), + /// [EXPERIMENTAL] Browse tasks from Codex Cloud and apply changes locally. #[clap(name = "cloud", alias = "cloud-tasks")] Cloud(CloudTasksCli), @@ -330,6 +343,70 @@ struct ForkCommand { config_overrides: TuiCli, } +#[derive(Debug, Parser)] +#[command(bin_name = "codex worktree")] +struct WorktreeCli { + #[command(subcommand)] + subcommand: WorktreeSubcommand, +} + +#[derive(Debug, clap::Subcommand)] +enum WorktreeSubcommand { + /// List Codex-managed worktrees for the current repository. + List(WorktreeListCommand), + + /// Print the workspace path for a managed worktree. + Path(WorktreePathCommand), + + /// Remove a Codex-managed worktree. + Remove(WorktreeRemoveCommand), + + /// Remove stale Codex-managed worktree metadata. + Prune(WorktreePruneCommand), +} + +#[derive(Debug, Args)] +struct WorktreeListCommand { + /// Include managed worktrees from all repositories. + #[arg(long = "all", default_value_t = false)] + all: bool, + + /// Print machine-readable JSON. + #[arg(long = "json", default_value_t = false)] + json: bool, +} + +#[derive(Debug, Args)] +struct WorktreePathCommand { + /// Managed worktree name or slug. + name: String, +} + +#[derive(Debug, Args)] +struct WorktreeRemoveCommand { + /// Managed worktree name, slug, or absolute path. + name_or_path: String, + + /// Remove even if the worktree is dirty. + #[arg(long = "force", short = 'f', default_value_t = false)] + force: bool, + + /// Delete the associated branch after removing the worktree. + #[arg(long = "delete-branch", default_value_t = false)] + delete_branch: bool, +} + +#[derive(Debug, Args)] +struct WorktreePruneCommand { + /// Show stale entries without deleting anything. + #[arg(long = "dry-run", default_value_t = false)] + dry_run: bool, + + /// Print machine-readable JSON. + #[arg(long = "json", default_value_t = false)] + json: bool, +} + #[derive(Debug, Parser)] struct SandboxArgs { #[command(subcommand)] @@ -666,6 +743,244 @@ async fn run_debug_app_server_command(cmd: DebugAppServerCommand) -> anyhow::Res } } +fn resolve_worktree_options_for_tui( + cli: &mut TuiCli, + remote: Option<&str>, + remote_auth_token_env: Option<&str>, +) -> anyhow::Result> { + resolve_worktree_options_for_shared_cli(&mut cli.shared, remote, remote_auth_token_env) +} + +fn resolve_worktree_options_for_shared_cli( + shared: &mut SharedCliOptions, + remote: Option<&str>, + remote_auth_token_env: Option<&str>, +) -> anyhow::Result> { + let Some(branch) = shared.worktree.take() else { + return Ok(None); + }; + + if remote.is_some() || remote_auth_token_env.is_some() { + anyhow::bail!("--worktree is not supported with remote app-server sessions yet"); + } + + let codex_home = find_codex_home()?.to_path_buf(); + let source_cwd = shared.cwd.clone().unwrap_or(std::env::current_dir()?); + let resolution = codex_worktree::ensure_worktree(WorktreeRequest { + codex_home, + source_cwd, + branch, + base_ref: shared.worktree_base.take(), + dirty_policy: dirty_policy_from_cli(shared.worktree_dirty), + })?; + shared.cwd = Some(resolution.info.workspace_cwd.clone()); + + #[allow(clippy::print_stderr)] + for warning in &resolution.warnings { + eprintln!("warning: {}", warning.message); + } + + Ok(Some(resolution)) +} + +fn dirty_policy_from_cli(arg: WorktreeDirtyCliArg) -> DirtyPolicy { + match arg { + WorktreeDirtyCliArg::Fail => DirtyPolicy::Fail, + WorktreeDirtyCliArg::Ignore => DirtyPolicy::Ignore, + WorktreeDirtyCliArg::CopyTracked => DirtyPolicy::CopyTracked, + WorktreeDirtyCliArg::CopyAll => DirtyPolicy::CopyAll, + } +} + +fn run_worktree_command(cli: WorktreeCli) -> anyhow::Result<()> { + let codex_home = find_codex_home()?.to_path_buf(); + match cli.subcommand { + WorktreeSubcommand::List(command) => { + let source_cwd = if command.all { + None + } else { + Some(std::env::current_dir()?) + }; + let entries = codex_worktree::list_worktrees(WorktreeListQuery { + codex_home, + source_cwd, + include_all_repos: command.all, + })?; + print_worktree_list(entries, command.json)?; + } + WorktreeSubcommand::Path(command) => { + let entries = codex_worktree::list_worktrees(WorktreeListQuery { + codex_home, + source_cwd: Some(std::env::current_dir()?), + include_all_repos: false, + })?; + let entry = find_named_worktree(entries, &command.name)?; + println!("{}", entry.workspace_cwd.display()); + } + WorktreeSubcommand::Remove(command) => { + let result = codex_worktree::remove_worktree(WorktreeRemoveRequest { + codex_home, + source_cwd: Some(std::env::current_dir()?), + name_or_path: command.name_or_path, + force: command.force, + delete_branch: command.delete_branch, + })?; + println!("removed {}", result.removed_path.display()); + if let Some(branch) = result.deleted_branch { + println!("deleted branch {branch}"); + } + } + WorktreeSubcommand::Prune(command) => { + let stale_paths = stale_managed_worktree_dirs(&codex_home)?; + if command.json { + println!("{}", serde_json::to_string_pretty(&stale_paths)?); + } else if stale_paths.is_empty() { + println!("No stale Codex-managed worktree directories found."); + } else { + for path in &stale_paths { + if command.dry_run { + println!("would remove {}", path.display()); + } else { + fs::remove_dir_all(path)?; + println!("removed {}", path.display()); + } + } + } + } + } + Ok(()) +} + +fn print_worktree_list(entries: Vec, json: bool) -> anyhow::Result<()> { + if json { + println!("{}", serde_json::to_string_pretty(&entries)?); + return Ok(()); + } + + let mut rows = Vec::new(); + for entry in &entries { + let status = if entry.dirty.is_dirty() { + "dirty" + } else { + "clean" + }; + rows.push([ + entry.branch.as_deref().unwrap_or(&entry.name).to_string(), + status.to_string(), + worktree_source_label(entry).to_string(), + entry + .owner_thread_id + .as_deref() + .unwrap_or("none") + .to_string(), + entry.workspace_cwd.display().to_string(), + ]); + } + let headers = ["BRANCH", "STATUS", "SOURCE", "THREAD", "PATH"]; + let mut widths = headers.map(str::len); + for row in &rows { + for (idx, cell) in row.iter().enumerate() { + widths[idx] = widths[idx].max(cell.len()); + } + } + println!( + "{branch: &'static str { + match entry.source { + codex_worktree::WorktreeSource::Cli => "cli", + codex_worktree::WorktreeSource::App => "app", + codex_worktree::WorktreeSource::Legacy => "legacy", + codex_worktree::WorktreeSource::Git => "git", + } +} + +fn find_named_worktree(entries: Vec, name: &str) -> anyhow::Result { + let matches = entries + .into_iter() + .filter(|entry| { + entry.branch.as_deref() == Some(name) || entry.name == name || entry.slug == name + }) + .collect::>(); + match matches.as_slice() { + [entry] => Ok(entry.clone()), + [] => anyhow::bail!("no managed worktree named {name}"), + _ => anyhow::bail!("multiple managed worktrees named {name}; pass a path instead"), + } +} + +fn stale_managed_worktree_dirs(codex_home: &Path) -> anyhow::Result> { + let root = codex_worktree::codex_worktrees_root(codex_home); + if !root.exists() { + return Ok(Vec::new()); + } + + let mut stale = Vec::new(); + for repo_dir in fs::read_dir(&root)? { + let repo_dir = repo_dir?; + if !repo_dir.file_type()?.is_dir() { + continue; + } + for slug_dir in fs::read_dir(repo_dir.path())? { + let slug_dir = slug_dir?; + if !slug_dir.file_type()?.is_dir() { + continue; + } + let mut has_repo_dir = false; + for repo_root in fs::read_dir(slug_dir.path())? { + let repo_root = repo_root?; + if !repo_root.file_type()?.is_dir() { + continue; + } + has_repo_dir = true; + if !repo_root.path().join(".git").exists() || !git_root_is_valid(&repo_root.path()) + { + stale.push(repo_root.path()); + } + } + if !has_repo_dir { + stale.push(slug_dir.path()); + } + } + } + stale.sort(); + Ok(stale) +} + +fn git_root_is_valid(path: &Path) -> bool { + std::process::Command::new("git") + .args(["rev-parse", "--show-toplevel"]) + .current_dir(path) + .output() + .is_ok_and(|output| output.status.success()) +} + #[derive(Debug, Default, Parser, Clone)] struct FeatureToggles { /// Enable a feature (repeatable). Equivalent to `-c features.=true`. @@ -774,6 +1089,11 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { &mut interactive.config_overrides, root_config_overrides.clone(), ); + resolve_worktree_options_for_tui( + &mut interactive, + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + )?; let exit_info = run_interactive_tui( interactive, root_remote.clone(), @@ -792,6 +1112,11 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { exec_cli .shared .inherit_exec_root_options(&interactive.shared); + resolve_worktree_options_for_shared_cli( + &mut exec_cli.shared, + /*remote*/ None, + /*remote_auth_token_env*/ None, + )?; prepend_config_flags( &mut exec_cli.config_overrides, root_config_overrides.clone(), @@ -942,6 +1267,14 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { include_non_interactive, config_overrides, ); + resolve_worktree_options_for_tui( + &mut interactive, + remote.remote.as_deref().or(root_remote.as_deref()), + remote + .remote_auth_token_env + .as_deref() + .or(root_remote_auth_token_env.as_deref()), + )?; let exit_info = run_interactive_tui( interactive, remote.remote.or(root_remote.clone()), @@ -968,6 +1301,14 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { all, config_overrides, ); + resolve_worktree_options_for_tui( + &mut interactive, + remote.remote.as_deref().or(root_remote.as_deref()), + remote + .remote_auth_token_env + .as_deref() + .or(root_remote_auth_token_env.as_deref()), + )?; let exit_info = run_interactive_tui( interactive, remote.remote.or(root_remote.clone()), @@ -979,6 +1320,14 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { .await?; handle_app_exit(exit_info)?; } + Some(Subcommand::Worktree(worktree_cli)) => { + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "worktree", + )?; + run_worktree_command(worktree_cli)?; + } Some(Subcommand::Login(mut login_cli)) => { reject_remote_mode_for_subcommand( root_remote.as_deref(), @@ -2045,6 +2394,45 @@ mod tests { assert!(result.is_err()); } + #[test] + fn top_level_worktree_flags_parse_into_interactive_shared_options() { + let cli = MultitoolCli::try_parse_from([ + "codex", + "--worktree", + "parser-fix", + "--worktree-base", + "origin/main", + "--worktree-dirty", + "copy-tracked", + ]) + .expect("worktree flags should parse"); + + assert_eq!(cli.interactive.worktree.as_deref(), Some("parser-fix")); + assert_eq!( + cli.interactive.worktree_base.as_deref(), + Some("origin/main") + ); + assert_eq!( + cli.interactive.worktree_dirty, + WorktreeDirtyCliArg::CopyTracked + ); + } + + #[test] + fn worktree_subcommand_parses() { + let cli = MultitoolCli::try_parse_from(["codex", "worktree", "list", "--all", "--json"]) + .expect("worktree list should parse"); + let Some(Subcommand::Worktree(WorktreeCli { + subcommand: WorktreeSubcommand::List(command), + })) = cli.subcommand + else { + panic!("expected worktree list subcommand"); + }; + + assert!(command.all); + assert!(command.json); + } + fn sample_exit_info(conversation_id: Option<&str>, thread_name: Option<&str>) -> AppExitInfo { let token_usage = TokenUsage { output_tokens: 2, diff --git a/codex-rs/exec/Cargo.toml b/codex-rs/exec/Cargo.toml index 632e47940476..94d77a2fc20a 100644 --- a/codex-rs/exec/Cargo.toml +++ b/codex-rs/exec/Cargo.toml @@ -37,6 +37,7 @@ codex-otel = { workspace = true } codex-protocol = { workspace = true } codex-utils-absolute-path = { workspace = true } codex-utils-cli = { workspace = true } +codex-worktree = { workspace = true } codex-utils-oss = { workspace = true } owo-colors = { workspace = true } serde = { workspace = true, features = ["derive"] } diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index a26dc525e6e9..db54d1be880b 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -257,6 +257,9 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result sandbox_mode: sandbox_mode_cli_arg, dangerously_bypass_approvals_and_sandbox, cwd, + worktree: _, + worktree_base: _, + worktree_dirty: _, add_dir, } = shared; @@ -693,6 +696,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { ) .await .map_err(anyhow::Error::msg)?; + bind_worktree_thread_best_effort(&config, response.cwd.as_path(), &response.thread.id); let session_configured = session_configured_from_thread_resume_response(&response, &config) .map_err(anyhow::Error::msg)?; @@ -708,6 +712,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { ) .await .map_err(anyhow::Error::msg)?; + bind_worktree_thread_best_effort(&config, response.cwd.as_path(), &response.thread.id); let session_configured = session_configured_from_thread_start_response(&response, &config) .map_err(anyhow::Error::msg)?; @@ -724,6 +729,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { ) .await .map_err(anyhow::Error::msg)?; + bind_worktree_thread_best_effort(&config, response.cwd.as_path(), &response.thread.id); let session_configured = session_configured_from_thread_start_response(&response, &config) .map_err(anyhow::Error::msg)?; (session_configured.thread_id, session_configured) @@ -1105,6 +1111,20 @@ fn session_configured_from_thread_resume_response( ) } +fn bind_worktree_thread_best_effort(config: &Config, cwd: &Path, thread_id: &str) { + match codex_worktree::resolve_worktree(config.codex_home.as_path(), cwd) { + Ok(Some(_)) => { + if let Err(err) = codex_worktree::bind_thread(cwd, thread_id) { + tracing::warn!(?err, "failed to bind managed worktree to thread"); + } + } + Ok(None) => {} + Err(err) => { + tracing::warn!(?err, "failed to resolve managed worktree metadata"); + } + } +} + fn review_target_to_api(target: ReviewTarget) -> ApiReviewTarget { match target { ReviewTarget::UncommittedChanges => ApiReviewTarget::UncommittedChanges, diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 5ef87bd9b929..6aa03c243eb9 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -55,6 +55,7 @@ codex-shell-command = { workspace = true } codex-state = { workspace = true } codex-terminal-detection = { workspace = true } codex-utils-approval-presets = { workspace = true } +codex-worktree = { workspace = true } codex-utils-absolute-path = { workspace = true } codex-utils-cli = { workspace = true } codex-utils-elapsed = { workspace = true } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index d6d65b04a4c0..176663058ae5 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -846,6 +846,9 @@ impl App { if let Some(message) = external_agent_config_migration_message { chat_widget.add_info_message(message, /*hint*/ None); } + if let Some(message) = managed_worktree_startup_message(&config) { + chat_widget.add_info_message(message, /*hint*/ None); + } chat_widget .maybe_prompt_windows_sandbox_enable(should_prompt_windows_sandbox_nux_at_startup); diff --git a/codex-rs/tui/src/app/startup_prompts.rs b/codex-rs/tui/src/app/startup_prompts.rs index 482c75b3fad4..3dde7d6cf8e4 100644 --- a/codex-rs/tui/src/app/startup_prompts.rs +++ b/codex-rs/tui/src/app/startup_prompts.rs @@ -77,6 +77,12 @@ pub(super) fn emit_system_bwrap_warning(app_event_tx: &AppEventSender, config: & ))); } +pub(super) fn managed_worktree_startup_message(config: &Config) -> Option { + let label = + crate::worktree_labels::label_for_cwd(config.codex_home.as_path(), config.cwd.as_path())?; + Some(format!("Workspace: {}", label.summary())) +} + pub(super) fn hooks_needing_review_warning(count: usize) -> Option { match count { 0 => None, diff --git a/codex-rs/tui/src/app/thread_routing.rs b/codex-rs/tui/src/app/thread_routing.rs index 1a7986ad31de..4ec0522204d0 100644 --- a/codex-rs/tui/src/app/thread_routing.rs +++ b/codex-rs/tui/src/app/thread_routing.rs @@ -497,6 +497,10 @@ impl App { op: &AppCommand, ) -> Result { match op { + AppCommand::AddToHistory { text } => { + self.append_message_history_entry(thread_id, text.to_string()); + Ok(true) + } AppCommand::Interrupt => { if let Some(turn_id) = self.active_turn_id_for_thread(thread_id).await { app_server.turn_interrupt(thread_id, turn_id).await?; diff --git a/codex-rs/tui/src/app_command.rs b/codex-rs/tui/src/app_command.rs index 89fd2600f8ac..c9e114e0a0f6 100644 --- a/codex-rs/tui/src/app_command.rs +++ b/codex-rs/tui/src/app_command.rs @@ -106,6 +106,9 @@ pub(crate) enum AppCommand { ApproveGuardianDeniedAction { event: GuardianAssessmentEvent, }, + AddToHistory { + text: String, + }, } impl AppCommand { @@ -272,6 +275,10 @@ impl AppCommand { Self::ApproveGuardianDeniedAction { event } } + pub(crate) fn add_to_history(text: String) -> Self { + Self::AddToHistory { text } + } + pub(crate) fn is_review(&self) -> bool { matches!(self, Self::Review { .. }) } diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 6a170beec794..92d59d302722 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -117,6 +117,7 @@ use color_eyre::eyre::ContextCompat; use color_eyre::eyre::Result; use color_eyre::eyre::WrapErr; use std::collections::HashMap; +use std::path::Path; use std::path::PathBuf; fn bootstrap_request_error(context: &'static str, err: TypedRequestError) -> color_eyre::Report { @@ -1328,6 +1329,7 @@ async fn thread_session_state_from_thread_start_response( config: &Config, thread_params_mode: ThreadParamsMode, ) -> Result { + bind_worktree_thread_best_effort(config, response.cwd.as_path(), &response.thread.id); let permission_profile = permission_profile_from_thread_response( &response.sandbox, response.permission_profile.as_ref(), @@ -1360,6 +1362,7 @@ async fn thread_session_state_from_thread_resume_response( config: &Config, thread_params_mode: ThreadParamsMode, ) -> Result { + bind_worktree_thread_best_effort(config, response.cwd.as_path(), &response.thread.id); let permission_profile = permission_profile_from_thread_response( &response.sandbox, response.permission_profile.as_ref(), @@ -1392,6 +1395,7 @@ async fn thread_session_state_from_thread_fork_response( config: &Config, thread_params_mode: ThreadParamsMode, ) -> Result { + bind_worktree_thread_best_effort(config, response.cwd.as_path(), &response.thread.id); let permission_profile = permission_profile_from_thread_response( &response.sandbox, response.permission_profile.as_ref(), @@ -1419,6 +1423,20 @@ async fn thread_session_state_from_thread_fork_response( .await } +fn bind_worktree_thread_best_effort(config: &Config, cwd: &Path, thread_id: &str) { + match codex_worktree::resolve_worktree(config.codex_home.as_path(), cwd) { + Ok(Some(_)) => { + if let Err(err) = codex_worktree::bind_thread(cwd, thread_id) { + tracing::warn!(?err, "failed to bind managed worktree to thread"); + } + } + Ok(None) => {} + Err(err) => { + tracing::warn!(?err, "failed to resolve managed worktree metadata"); + } + } +} + fn permission_profile_from_thread_response( sandbox: &codex_app_server_protocol::SandboxPolicy, permission_profile: Option<&codex_app_server_protocol::PermissionProfile>, diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index b2e92a19b485..26ec4ec6e245 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -179,6 +179,7 @@ mod tui; mod ui_consts; pub(crate) mod update_action; pub use update_action::UpdateAction; +mod worktree_labels; #[cfg(not(debug_assertions))] pub use update_action::get_update_action; mod update_prompt; diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index 08bd6657bf53..d2357e6e7db2 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -23,6 +23,8 @@ use crate::text_formatting::truncate_text; use crate::tui::FrameRequester; use crate::tui::Tui; use crate::tui::TuiEvent; +use crate::worktree_labels::WorktreeLabel; +use crate::worktree_labels::label_for_cwd; use crate::wrapping::RtOptions; use crate::wrapping::adaptive_wrap_lines; use chrono::DateTime; @@ -375,6 +377,7 @@ async fn run_resume_picker_with_launch_context( app_server, include_non_interactive, raw_reasoning_visibility(config), + (!is_remote).then(|| config.codex_home.to_path_buf()), bg_tx, ), bg_rx, @@ -420,6 +423,7 @@ pub async fn run_fork_picker_with_app_server( app_server, /*include_non_interactive*/ false, raw_reasoning_visibility(config), + (!is_remote).then(|| config.codex_home.to_path_buf()), bg_tx, ), bg_rx, @@ -540,6 +544,7 @@ fn spawn_app_server_page_loader( app_server: AppServerSession, include_non_interactive: bool, raw_reasoning_visibility: RawReasoningVisibility, + codex_home: Option, bg_tx: mpsc::UnboundedSender, ) -> PickerLoader { let (request_tx, mut request_rx) = mpsc::unbounded_channel::(); @@ -557,6 +562,7 @@ fn spawn_app_server_page_loader( request.provider_filter, request.sort_key, include_non_interactive, + codex_home.as_deref(), ) .await; let _ = bg_tx.send(BackgroundEvent::Page { @@ -725,6 +731,7 @@ async fn load_app_server_page( provider_filter: ProviderFilter, sort_key: ThreadSortKey, include_non_interactive: bool, + codex_home: Option<&Path>, ) -> std::io::Result { let response = app_server .thread_list(thread_list_params( @@ -742,7 +749,7 @@ async fn load_app_server_page( rows: response .data .into_iter() - .filter_map(row_from_app_server_thread) + .filter_map(|thread| row_from_app_server_thread(thread, codex_home)) .collect(), next_cursor: response.next_cursor.map(PageCursor::AppServer), num_scanned_files, @@ -824,6 +831,7 @@ struct Row { updated_at: Option>, cwd: Option, git_branch: Option, + worktree_label: Option, } #[derive(Clone, Debug, Eq, Hash, PartialEq)] @@ -844,6 +852,24 @@ impl Row { self.thread_name.as_deref().unwrap_or(&self.preview) } + fn display_branch(&self) -> Option<&str> { + self.worktree_label + .as_ref() + .and_then(|label| label.branch.as_deref()) + .or(self.git_branch.as_deref()) + } + + fn display_cwd(&self) -> Option { + let cwd = self + .cwd + .as_ref() + .map(|path| format_directory_display(path, /*max_width*/ None))?; + Some(match self.worktree_label.as_ref() { + Some(label) => format!("{} · {cwd}", label.summary()), + None => cwd, + }) + } + fn matches_query(&self, query: &str) -> bool { if self.preview.to_lowercase().contains(query) { return true; @@ -873,6 +899,16 @@ impl Row { { return true; } + if let Some(label) = self.worktree_label.as_ref() + && (label.name.to_lowercase().contains(query) + || label.repo_name.to_lowercase().contains(query) + || label + .branch + .as_ref() + .is_some_and(|branch| branch.to_lowercase().contains(query))) + { + return true; + } false } } @@ -1793,7 +1829,7 @@ impl PickerState { } } -fn row_from_app_server_thread(thread: Thread) -> Option { +fn row_from_app_server_thread(thread: Thread, codex_home: Option<&Path>) -> Option { let thread_id = match ThreadId::from_string(&thread.id) { Ok(thread_id) => thread_id, Err(err) => { @@ -1802,6 +1838,8 @@ fn row_from_app_server_thread(thread: Thread) -> Option { } }; let preview = thread.preview.trim(); + let cwd = thread.cwd.to_path_buf(); + let worktree_label = codex_home.and_then(|codex_home| label_for_cwd(codex_home, &cwd)); Some(Row { path: thread.path, preview: if preview.is_empty() { @@ -1815,8 +1853,9 @@ fn row_from_app_server_thread(thread: Thread) -> Option { .map(|dt| dt.with_timezone(&Utc)), updated_at: chrono::DateTime::from_timestamp(thread.updated_at, 0) .map(|dt| dt.with_timezone(&Utc)), - cwd: Some(thread.cwd.to_path_buf()), + cwd: Some(cwd), git_branch: thread.git_info.and_then(|git_info| git_info.branch), + worktree_label, }) } @@ -2571,11 +2610,8 @@ fn render_comfortable_session_lines( let reference = state.relative_time_reference.unwrap_or_else(Utc::now); let created = format_relative_time(reference, row.created_at); let updated = format_relative_time(reference, row.updated_at.or(row.created_at)); - let branch = row.git_branch.as_deref(); - let cwd = row - .cwd - .as_ref() - .map(|path| format_directory_display(path, /*max_width*/ None)); + let branch = row.display_branch(); + let cwd = row.display_cwd(); let footer_lines = render_footer_lines( state.sort_key, &created, @@ -2973,12 +3009,10 @@ fn render_expanded_session_details( .map(|path| format_directory_display(path, /*max_width*/ None)) .unwrap_or_else(|| "-".to_string()); let branch = row - .git_branch - .as_ref() + .display_branch() .map(|branch| format!("{SESSION_META_BRANCH_ICON} {branch}")) .unwrap_or_else(|| format!("{SESSION_META_BRANCH_ICON} no branch")); - - vec![ + let mut details = vec![ expanded_detail_line("Session:", &session, width), expanded_time_detail_line("Created:", reference, row.created_at, width), expanded_time_detail_line( @@ -2987,11 +3021,17 @@ fn render_expanded_session_details( row.updated_at.or(row.created_at), width, ), + ]; + if let Some(worktree) = row.worktree_label.as_ref().map(WorktreeLabel::summary) { + details.push(expanded_detail_line("Workspace:", &worktree, width)); + } + details.extend([ expanded_detail_line("Directory:", &directory, width), expanded_detail_line("Branch:", &branch, width), vec![" │".dim()].into(), vec![" │ ".dim(), "Conversation:".dim()].into(), - ] + ]); + details } fn render_conversation_preview_lines( @@ -3263,6 +3303,7 @@ mod tests { updated_at: Some(timestamp), cwd: None, git_branch: None, + worktree_label: None, } } @@ -3309,6 +3350,7 @@ mod tests { updated_at: None, cwd: None, git_branch: None, + worktree_label: None, }; assert_eq!(row.display_preview(), "My session"); @@ -3349,6 +3391,7 @@ mod tests { updated_at: None, cwd: Some(PathBuf::from("/tmp/codex-session-picker")), git_branch: Some(String::from("fcoury/session-picker")), + worktree_label: None, }; assert!(row.matches_query("session-picker")); @@ -3356,6 +3399,37 @@ mod tests { assert!(row.matches_query(&thread_id.to_string()[..8])); } + #[test] + fn row_worktree_label_overrides_branch_and_prefixes_cwd() { + let row = Row { + path: Some(PathBuf::from("/tmp/a.jsonl")), + preview: String::from("first message"), + thread_id: Some(ThreadId::new()), + thread_name: None, + created_at: None, + updated_at: None, + cwd: Some(PathBuf::from( + "/Users/felipe.coury/.codex/worktrees/abcd/parser-fix/codex", + )), + git_branch: Some(String::from("main")), + worktree_label: Some(WorktreeLabel { + name: String::from("parser-fix"), + branch: Some(String::from("parser-fix")), + repo_name: String::from("codex"), + dirty: false, + }), + }; + + assert_eq!(row.display_branch(), Some("parser-fix")); + assert!( + row.display_cwd() + .expect("cwd") + .starts_with("parser-fix · clean · codex · ") + ); + assert!(row.matches_query("parser-fix")); + assert!(row.matches_query("codex")); + } + #[test] fn relative_time_formats_zero_seconds_as_now() { let reference = DateTime::parse_from_rfc3339("2026-05-02T12:00:00Z") @@ -3409,6 +3483,7 @@ mod tests { updated_at: parse_timestamp_str("2026-05-02T14:48:19Z"), cwd: Some(PathBuf::from("/Users/felipe.coury/code/codex")), git_branch: Some(String::from("codex/raw-scrollback-mode")), + worktree_label: None, }; let rendered = render_expanded_session_details(&row, &state, /*width*/ 120) @@ -3615,6 +3690,7 @@ mod tests { updated_at: None, cwd: Some(PathBuf::from("/srv/real-project")), git_branch: None, + worktree_label: None, }; assert!(state.row_matches_filter(&row)); @@ -3640,6 +3716,7 @@ mod tests { updated_at: None, cwd: Some(PathBuf::from("/srv/remote-project")), git_branch: None, + worktree_label: None, }; assert!(state.row_matches_filter(&row)); @@ -3671,6 +3748,7 @@ mod tests { updated_at: Some(now - Duration::seconds(42)), cwd: None, git_branch: None, + worktree_label: None, }, Row { path: Some(PathBuf::from("/tmp/b.jsonl")), @@ -3681,6 +3759,7 @@ mod tests { updated_at: Some(now - Duration::minutes(35)), cwd: None, git_branch: None, + worktree_label: None, }, Row { path: Some(PathBuf::from("/tmp/c.jsonl")), @@ -3691,6 +3770,7 @@ mod tests { updated_at: Some(now - Duration::hours(2)), cwd: None, git_branch: None, + worktree_label: None, }, ]; state.all_rows = rows.clone(); @@ -4119,6 +4199,7 @@ mod tests { updated_at: None, cwd: None, git_branch: None, + worktree_label: None, }]; state @@ -4157,6 +4238,7 @@ mod tests { updated_at: None, cwd: None, git_branch: None, + worktree_label: None, }, Row { path: None, @@ -4167,6 +4249,7 @@ mod tests { updated_at: None, cwd: None, git_branch: None, + worktree_label: None, }, ]; state.pending_transcript_open = Some(thread_id); @@ -4236,6 +4319,7 @@ mod tests { updated_at: None, cwd: None, git_branch: None, + worktree_label: None, }, Row { path: None, @@ -4246,6 +4330,7 @@ mod tests { updated_at: None, cwd: None, git_branch: None, + worktree_label: None, }, ]; state.update_viewport(/*rows*/ 7, /*width*/ 80); @@ -4301,6 +4386,7 @@ mod tests { updated_at: None, cwd: None, git_branch: None, + worktree_label: None, }]; state @@ -4331,6 +4417,7 @@ mod tests { updated_at: None, cwd: None, git_branch: None, + worktree_label: None, }]; state @@ -4407,6 +4494,7 @@ mod tests { updated_at: None, cwd: None, git_branch: None, + worktree_label: None, }]; state.transcript_cells.insert( thread_id, @@ -4606,6 +4694,7 @@ session_picker_view = "dense" updated_at: None, cwd: None, git_branch: None, + worktree_label: None, }]; state @@ -4645,6 +4734,7 @@ session_picker_view = "dense" updated_at: None, cwd: None, git_branch: None, + worktree_label: None, }]; state @@ -4723,6 +4813,7 @@ session_picker_view = "dense" "/Users/felipe.coury/code/codex.fcoury-session-picker/codex-rs", )), git_branch: Some(String::from("fcoury/session-picker")), + worktree_label: None, } } @@ -4975,6 +5066,7 @@ session_picker_view = "dense" updated_at: parse_timestamp_str("2026-04-28T17:45:00Z"), cwd: Some(PathBuf::from("/tmp/codex")), git_branch: Some(String::from("fcoury/session-picker")), + worktree_label: None, }; let mut state = PickerState::new( FrameRequester::test_dummy(), @@ -5044,6 +5136,7 @@ session_picker_view = "dense" updated_at: parse_timestamp_str("2026-04-28T17:45:00Z"), cwd: Some(PathBuf::from("/tmp/codex")), git_branch: Some(String::from("fcoury/session-picker")), + worktree_label: None, }; let mut state = PickerState::new( FrameRequester::test_dummy(), @@ -5102,6 +5195,7 @@ session_picker_view = "dense" updated_at: Some(now - Duration::minutes(idx * 5)), cwd: None, git_branch: None, + worktree_label: None, }) .collect(); state.filtered_rows = state.all_rows.clone(); @@ -5154,6 +5248,7 @@ session_picker_view = "dense" updated_at: Some(now - Duration::minutes(idx * 5)), cwd: None, git_branch: None, + worktree_label: None, }) .collect(); state.filtered_rows = state.all_rows.clone(); @@ -5622,6 +5717,7 @@ session_picker_view = "dense" updated_at: None, cwd: None, git_branch: None, + worktree_label: None, }; state.all_rows = vec![row.clone()]; state.filtered_rows = vec![row]; @@ -5661,6 +5757,7 @@ session_picker_view = "dense" updated_at: None, cwd: None, git_branch: None, + worktree_label: None, }; state.all_rows = vec![row.clone()]; state.filtered_rows = vec![row]; @@ -5704,7 +5801,8 @@ session_picker_view = "dense" turns: Vec::new(), }; - let row = row_from_app_server_thread(thread).expect("row should be preserved"); + let row = row_from_app_server_thread(thread, /*codex_home*/ None) + .expect("row should be preserved"); assert_eq!(row.path, None); assert_eq!(row.thread_id, Some(thread_id)); diff --git a/codex-rs/tui/src/worktree_labels.rs b/codex-rs/tui/src/worktree_labels.rs new file mode 100644 index 000000000000..2976869e6b81 --- /dev/null +++ b/codex-rs/tui/src/worktree_labels.rs @@ -0,0 +1,49 @@ +use std::path::Path; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct WorktreeLabel { + pub(crate) name: String, + pub(crate) branch: Option, + pub(crate) repo_name: String, + pub(crate) dirty: bool, +} + +impl WorktreeLabel { + pub(crate) fn summary(&self) -> String { + let mut parts = vec![self.branch.clone().unwrap_or_else(|| self.name.clone())]; + parts.push(if self.dirty { "dirty" } else { "clean" }.to_string()); + parts.push(self.repo_name.clone()); + parts.join(" · ") + } +} + +pub(crate) fn label_for_cwd(codex_home: &Path, cwd: &Path) -> Option { + let info = codex_worktree::resolve_worktree(codex_home, cwd) + .inspect_err(|err| tracing::warn!(?err, "failed to resolve managed worktree label")) + .ok() + .flatten()?; + Some(WorktreeLabel { + name: info.name, + branch: info.branch, + repo_name: info.repo_name, + dirty: info.dirty.is_dirty(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn summary_includes_name_branch_and_repo() { + let label = WorktreeLabel { + name: String::from("parser-fix"), + branch: Some(String::from("parser-fix")), + repo_name: String::from("codex"), + dirty: false, + }; + + assert_eq!(label.summary(), "parser-fix · clean · codex"); + } +} diff --git a/codex-rs/utils/cli/src/lib.rs b/codex-rs/utils/cli/src/lib.rs index 35e4fd1daf4d..f1294f3eb0dc 100644 --- a/codex-rs/utils/cli/src/lib.rs +++ b/codex-rs/utils/cli/src/lib.rs @@ -3,9 +3,11 @@ mod config_override; pub(crate) mod format_env_display; mod sandbox_mode_cli_arg; mod shared_options; +mod worktree_dirty_cli_arg; pub use approval_mode_cli_arg::ApprovalModeCliArg; pub use config_override::CliConfigOverrides; pub use format_env_display::format_env_display; pub use sandbox_mode_cli_arg::SandboxModeCliArg; pub use shared_options::SharedCliOptions; +pub use worktree_dirty_cli_arg::WorktreeDirtyCliArg; diff --git a/codex-rs/utils/cli/src/shared_options.rs b/codex-rs/utils/cli/src/shared_options.rs index c174a7b5a654..460ece0e37c3 100644 --- a/codex-rs/utils/cli/src/shared_options.rs +++ b/codex-rs/utils/cli/src/shared_options.rs @@ -1,6 +1,7 @@ //! Shared command-line flags used by both interactive and non-interactive Codex entry points. use crate::SandboxModeCliArg; +use crate::WorktreeDirtyCliArg; use clap::Args; use std::path::PathBuf; @@ -51,6 +52,18 @@ pub struct SharedCliOptions { #[clap(long = "cd", short = 'C', value_name = "DIR")] pub cwd: Option, + /// Create or reuse a Codex-managed Git worktree for this branch and run from that workspace. + #[arg(long = "worktree", value_name = "BRANCH")] + pub worktree: Option, + + /// Base ref for a newly created managed worktree. + #[arg(long = "worktree-base", value_name = "REF")] + pub worktree_base: Option, + + /// How to handle uncommitted source checkout changes when creating a worktree. + #[arg(long = "worktree-dirty", value_enum, default_value_t = WorktreeDirtyCliArg::Fail)] + pub worktree_dirty: WorktreeDirtyCliArg, + /// 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, @@ -69,6 +82,9 @@ impl SharedCliOptions { sandbox_mode, dangerously_bypass_approvals_and_sandbox, cwd, + worktree, + worktree_base, + worktree_dirty, add_dir, } = self; let Self { @@ -80,6 +96,9 @@ impl SharedCliOptions { sandbox_mode: root_sandbox_mode, dangerously_bypass_approvals_and_sandbox: root_dangerously_bypass_approvals_and_sandbox, cwd: root_cwd, + worktree: root_worktree, + worktree_base: root_worktree_base, + worktree_dirty: root_worktree_dirty, add_dir: root_add_dir, } = root; @@ -105,6 +124,15 @@ impl SharedCliOptions { if cwd.is_none() { cwd.clone_from(root_cwd); } + if worktree.is_none() { + worktree.clone_from(root_worktree); + } + if worktree_base.is_none() { + worktree_base.clone_from(root_worktree_base); + } + if *worktree_dirty == WorktreeDirtyCliArg::Fail { + *worktree_dirty = *root_worktree_dirty; + } if !root_images.is_empty() { let mut merged_images = root_images.clone(); merged_images.append(images); @@ -129,6 +157,9 @@ impl SharedCliOptions { sandbox_mode, dangerously_bypass_approvals_and_sandbox, cwd, + worktree, + worktree_base, + worktree_dirty, add_dir, } = subcommand; @@ -152,6 +183,15 @@ impl SharedCliOptions { if let Some(cwd) = cwd { self.cwd = Some(cwd); } + if let Some(worktree) = worktree { + self.worktree = Some(worktree); + } + if let Some(worktree_base) = worktree_base { + self.worktree_base = Some(worktree_base); + } + if worktree_dirty != WorktreeDirtyCliArg::Fail { + self.worktree_dirty = worktree_dirty; + } if !images.is_empty() { self.images = images; } diff --git a/codex-rs/utils/cli/src/worktree_dirty_cli_arg.rs b/codex-rs/utils/cli/src/worktree_dirty_cli_arg.rs new file mode 100644 index 000000000000..b7843957dd60 --- /dev/null +++ b/codex-rs/utils/cli/src/worktree_dirty_cli_arg.rs @@ -0,0 +1,11 @@ +use clap::ValueEnum; + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, ValueEnum)] +#[value(rename_all = "kebab-case")] +pub enum WorktreeDirtyCliArg { + #[default] + Fail, + Ignore, + CopyTracked, + CopyAll, +} diff --git a/codex-rs/worktree/BUILD.bazel b/codex-rs/worktree/BUILD.bazel new file mode 100644 index 000000000000..8233781f0f36 --- /dev/null +++ b/codex-rs/worktree/BUILD.bazel @@ -0,0 +1,7 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "worktree", + crate_name = "codex_worktree", +) + diff --git a/codex-rs/worktree/Cargo.toml b/codex-rs/worktree/Cargo.toml new file mode 100644 index 000000000000..b137a87a20ba --- /dev/null +++ b/codex-rs/worktree/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "codex-worktree" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lints] +workspace = true + +[dependencies] +anyhow = { workspace = true } +codex-utils-absolute-path = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +sha2 = { workspace = true } + +[dev-dependencies] +pretty_assertions = { workspace = true } +tempfile = { workspace = true } diff --git a/codex-rs/worktree/src/dirty.rs b/codex-rs/worktree/src/dirty.rs new file mode 100644 index 000000000000..5d2a13bda44e --- /dev/null +++ b/codex-rs/worktree/src/dirty.rs @@ -0,0 +1,182 @@ +use std::fs; +use std::path::Component; +use std::path::Path; +use std::path::PathBuf; + +use anyhow::Context; +use anyhow::Result; +use serde::Deserialize; +use serde::Serialize; + +use crate::git; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DirtyPolicy { + Fail, + Ignore, + CopyTracked, + CopyAll, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DirtyState { + pub has_staged_changes: bool, + pub has_unstaged_changes: bool, + pub has_untracked_files: bool, +} + +impl DirtyState { + pub fn is_dirty(&self) -> bool { + self.has_staged_changes || self.has_unstaged_changes || self.has_untracked_files + } +} + +pub fn dirty_state(root: &Path) -> Result { + let staged = git::bytes(root, &["diff", "--cached", "--name-only", "-z"])?; + let unstaged = git::bytes(root, &["diff", "--name-only", "-z"])?; + let untracked = git::bytes(root, &["ls-files", "--others", "--exclude-standard", "-z"])?; + Ok(DirtyState { + has_staged_changes: !staged.is_empty(), + has_unstaged_changes: !unstaged.is_empty(), + has_untracked_files: !untracked.is_empty(), + }) +} + +pub fn validate_dirty_policy_before_create( + source_root: &Path, + policy: DirtyPolicy, +) -> Result> { + let state = dirty_state(source_root)?; + if !state.is_dirty() { + return Ok(Vec::new()); + } + + match policy { + DirtyPolicy::Fail => bail_for_dirty_source(), + DirtyPolicy::Ignore => Ok(vec![ + "source checkout has uncommitted changes; the new worktree was created without them" + .to_string(), + ]), + DirtyPolicy::CopyTracked => { + if state.has_untracked_files { + Ok(vec![ + "untracked files were left in the source checkout; use --worktree-dirty copy-all to copy them" + .to_string(), + ]) + } else { + Ok(Vec::new()) + } + } + DirtyPolicy::CopyAll => Ok(Vec::new()), + } +} + +pub fn apply_dirty_policy_after_create( + source_root: &Path, + worktree_root: &Path, + policy: DirtyPolicy, +) -> Result<()> { + let state = dirty_state(source_root)?; + if !state.is_dirty() { + return Ok(()); + } + + match policy { + DirtyPolicy::Fail | DirtyPolicy::Ignore => Ok(()), + DirtyPolicy::CopyTracked => apply_tracked_diff(source_root, worktree_root), + DirtyPolicy::CopyAll => { + apply_tracked_diff(source_root, worktree_root)?; + copy_untracked_files(source_root, worktree_root)?; + Ok(()) + } + } +} + +fn bail_for_dirty_source() -> Result { + anyhow::bail!( + "source checkout has uncommitted changes; use --worktree-dirty ignore, copy-tracked, or copy-all" + ); +} + +fn apply_tracked_diff(source_root: &Path, worktree_root: &Path) -> Result<()> { + let staged = git::bytes(source_root, &["diff", "--cached", "--binary"])?; + let unstaged = git::bytes(source_root, &["diff", "--binary"])?; + + if !staged.is_empty() { + git::status_with_stdin( + worktree_root, + &["apply", "--index", "--binary", "-"], + &staged, + ) + .context("failed to apply staged changes to worktree")?; + } + if !unstaged.is_empty() { + git::status_with_stdin(worktree_root, &["apply", "--binary", "-"], &unstaged) + .context("failed to apply unstaged changes to worktree")?; + } + Ok(()) +} + +fn copy_untracked_files(source_root: &Path, worktree_root: &Path) -> Result<()> { + let output = git::bytes( + source_root, + &["ls-files", "--others", "--exclude-standard", "-z"], + )?; + for raw_path in output + .split(|byte| *byte == 0) + .filter(|path| !path.is_empty()) + { + let relative_path = PathBuf::from(String::from_utf8_lossy(raw_path).into_owned()); + ensure_safe_relative_path(&relative_path)?; + let source = source_root.join(&relative_path); + let destination = worktree_root.join(&relative_path); + let metadata = fs::symlink_metadata(&source)?; + if let Some(parent) = destination.parent() { + fs::create_dir_all(parent)?; + } + if metadata.file_type().is_symlink() { + let target = fs::read_link(&source)?; + create_symlink(&target, &destination)?; + } else if metadata.is_file() { + fs::copy(&source, &destination)?; + } + } + Ok(()) +} + +fn ensure_safe_relative_path(path: &Path) -> Result<()> { + if path.is_absolute() { + anyhow::bail!( + "refusing to copy absolute untracked path {}", + path.display() + ); + } + if path.components().any(|component| { + matches!(component, Component::ParentDir) + || matches!(component, Component::Normal(value) if value == ".git") + }) { + anyhow::bail!("refusing to copy unsafe untracked path {}", path.display()); + } + Ok(()) +} + +#[cfg(unix)] +fn create_symlink(target: &Path, destination: &Path) -> Result<()> { + std::os::unix::fs::symlink(target, destination).map_err(Into::into) +} + +#[cfg(windows)] +fn create_symlink(target: &Path, destination: &Path) -> Result<()> { + std::os::windows::fs::symlink_file(target, destination).map_err(Into::into) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dirty_state_reports_clean_by_default() { + assert!(!DirtyState::default().is_dirty()); + } +} diff --git a/codex-rs/worktree/src/git.rs b/codex-rs/worktree/src/git.rs new file mode 100644 index 000000000000..43acfca47c08 --- /dev/null +++ b/codex-rs/worktree/src/git.rs @@ -0,0 +1,57 @@ +use std::path::Path; +use std::process::Command; +use std::process::Stdio; + +use anyhow::Context; +use anyhow::Result; + +pub fn stdout(cwd: &Path, args: &[&str]) -> Result { + let output = output(cwd, args)?; + Ok(String::from_utf8(output)?.trim_end().to_string()) +} + +pub fn bytes(cwd: &Path, args: &[&str]) -> Result> { + output(cwd, args) +} + +pub fn status(cwd: &Path, args: &[&str]) -> Result<()> { + output(cwd, args).map(|_| ()) +} + +pub fn status_with_stdin(cwd: &Path, args: &[&str], stdin: &[u8]) -> Result<()> { + let mut child = Command::new("git") + .args(args) + .current_dir(cwd) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .with_context(|| format!("failed to spawn git {}", args.join(" ")))?; + use std::io::Write as _; + child + .stdin + .as_mut() + .context("git stdin unavailable")? + .write_all(stdin)?; + let output = child.wait_with_output()?; + if !output.status.success() { + anyhow::bail!( + "git {} failed: {}", + args.join(" "), + String::from_utf8_lossy(&output.stderr).trim() + ); + } + Ok(()) +} + +pub fn output(cwd: &Path, args: &[&str]) -> Result> { + let output = Command::new("git").args(args).current_dir(cwd).output()?; + if !output.status.success() { + anyhow::bail!( + "git {} failed: {}", + args.join(" "), + String::from_utf8_lossy(&output.stderr).trim() + ); + } + Ok(output.stdout) +} diff --git a/codex-rs/worktree/src/lib.rs b/codex-rs/worktree/src/lib.rs new file mode 100644 index 000000000000..a5adb60bc76d --- /dev/null +++ b/codex-rs/worktree/src/lib.rs @@ -0,0 +1,105 @@ +mod dirty; +mod git; +mod manager; +mod metadata; +mod paths; + +use std::path::PathBuf; + +use serde::Deserialize; +use serde::Serialize; + +pub use dirty::DirtyPolicy; +pub use dirty::DirtyState; +pub use manager::ensure_worktree; +pub use manager::list_worktrees; +pub use manager::remove_worktree; +pub use manager::resolve_worktree; +pub use metadata::bind_thread; +pub use metadata::read_worktree_metadata; +pub use metadata::write_worktree_metadata; +pub use paths::codex_worktrees_root; +pub use paths::is_managed_worktree_path; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorktreeRequest { + pub codex_home: PathBuf, + pub source_cwd: PathBuf, + pub branch: String, + pub base_ref: Option, + pub dirty_policy: DirtyPolicy, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorktreeResolution { + pub reused: bool, + pub info: WorktreeInfo, + pub warnings: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorktreeInfo { + pub id: String, + pub name: String, + pub slug: String, + pub source: WorktreeSource, + pub location: WorktreeLocation, + pub repo_name: String, + pub repo_root: PathBuf, + pub common_git_dir: PathBuf, + pub worktree_git_root: PathBuf, + pub workspace_cwd: PathBuf, + pub original_relative_cwd: PathBuf, + pub branch: Option, + pub head: Option, + pub owner_thread_id: Option, + pub metadata_path: PathBuf, + pub dirty: DirtyState, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum WorktreeSource { + Cli, + App, + Legacy, + Git, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum WorktreeLocation { + Sibling, + CodexHome, + External, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorktreeWarning { + pub message: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorktreeListQuery { + pub codex_home: PathBuf, + pub source_cwd: Option, + pub include_all_repos: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorktreeRemoveRequest { + pub codex_home: PathBuf, + pub source_cwd: Option, + pub name_or_path: String, + pub force: bool, + pub delete_branch: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorktreeRemoveResult { + pub removed_path: PathBuf, + pub deleted_branch: Option, +} diff --git a/codex-rs/worktree/src/manager.rs b/codex-rs/worktree/src/manager.rs new file mode 100644 index 000000000000..e0b20248e99d --- /dev/null +++ b/codex-rs/worktree/src/manager.rs @@ -0,0 +1,604 @@ +use std::fs; +use std::path::Path; +use std::path::PathBuf; + +use anyhow::Context; +use anyhow::Result; + +use crate::WorktreeInfo; +use crate::WorktreeListQuery; +use crate::WorktreeLocation; +use crate::WorktreeRemoveRequest; +use crate::WorktreeRemoveResult; +use crate::WorktreeRequest; +use crate::WorktreeResolution; +use crate::WorktreeSource; +use crate::WorktreeWarning; +use crate::dirty; +use crate::git; +use crate::metadata; +use crate::metadata::WorktreeMetadata; +use crate::paths; + +pub fn ensure_worktree(req: WorktreeRequest) -> Result { + let repo = SourceRepo::resolve(&req.source_cwd)?; + let branch = req.branch.clone(); + let slug = paths::slugify_name(&branch)?; + ensure_safe_branch_name(&repo.root, &branch)?; + let worktree_git_root = paths::sibling_worktree_git_root(&repo.root, &branch)?; + let workspace_cwd = worktree_git_root.join(&repo.relative_cwd); + + if worktree_git_root.exists() { + let Some(metadata) = metadata::read_worktree_metadata(&worktree_git_root)? else { + anyhow::bail!( + "managed worktree path {} already exists but is not owned by Codex", + worktree_git_root.display() + ); + }; + ensure_existing_worktree_matches_branch(&worktree_git_root, &metadata, &branch)?; + let info = info_from_existing_worktree( + &req.codex_home, + &worktree_git_root, + Some(branch), + Some(slug), + )?; + return Ok(WorktreeResolution { + reused: true, + info, + warnings: Vec::new(), + }); + } + + if let Some(path) = branch_checkout_path(&repo.root, &branch)? + && path != worktree_git_root + { + anyhow::bail!( + "branch {branch} is already checked out at {}; remove that worktree first", + path.display() + ); + } + + let warnings = dirty::validate_dirty_policy_before_create(&repo.root, req.dirty_policy)?; + let base_ref = req.base_ref.as_deref().unwrap_or("HEAD"); + fs::create_dir_all( + worktree_git_root + .parent() + .context("managed worktree path has no parent")?, + )?; + if branch_exists(&repo.root, &branch) { + git::status( + &repo.root, + &[ + "worktree", + "add", + &worktree_git_root.to_string_lossy(), + &branch, + ], + )?; + } else { + git::status( + &repo.root, + &[ + "worktree", + "add", + "-b", + &branch, + &worktree_git_root.to_string_lossy(), + base_ref, + ], + )?; + } + + dirty::apply_dirty_policy_after_create(&repo.root, &worktree_git_root, req.dirty_policy)?; + let dirty = dirty::dirty_state(&worktree_git_root)?; + let head = git::stdout(&worktree_git_root, &["rev-parse", "HEAD"]).ok(); + let mut info = WorktreeInfo { + id: repo.id.clone(), + name: branch.clone(), + slug, + source: WorktreeSource::Cli, + location: WorktreeLocation::Sibling, + repo_name: repo.repo_name.clone(), + repo_root: repo.root.clone(), + common_git_dir: repo.common_git_dir.clone(), + worktree_git_root: worktree_git_root.clone(), + workspace_cwd, + original_relative_cwd: repo.relative_cwd.clone(), + branch: Some(branch), + head, + owner_thread_id: None, + metadata_path: metadata_path_for_display(&worktree_git_root)?, + dirty, + }; + metadata::write_pending_owner_metadata(&worktree_git_root)?; + let worktree_metadata = WorktreeMetadata::from_info(&info, repo.root); + metadata::write_worktree_metadata(&worktree_git_root, &worktree_metadata)?; + info.owner_thread_id = worktree_metadata.owner_thread_id; + Ok(WorktreeResolution { + reused: false, + info, + warnings: warnings + .into_iter() + .map(|message| WorktreeWarning { message }) + .collect(), + }) +} + +pub fn resolve_worktree(codex_home: &Path, cwd: &Path) -> Result> { + let Ok(root) = git::stdout(cwd, &["rev-parse", "--show-toplevel"]) else { + return Ok(None); + }; + let root = PathBuf::from(root); + if !paths::is_managed_worktree_path(&root, codex_home) + && metadata::read_worktree_metadata(&root)?.is_none() + { + return Ok(None); + } + Ok(Some(info_from_existing_worktree( + codex_home, &root, /*fallback_name*/ None, /*fallback_slug*/ None, + )?)) +} + +pub fn list_worktrees(query: WorktreeListQuery) -> Result> { + let repo_filter = if query.include_all_repos { + None + } else { + let source_cwd = query + .source_cwd + .as_ref() + .context("source cwd is required unless include_all_repos is true")?; + Some(SourceRepo::resolve(source_cwd)?) + }; + let mut entries = Vec::new(); + if let Some(repo_filter) = repo_filter.as_ref() { + for worktree in parse_worktree_list(&git::stdout( + &repo_filter.root, + &["worktree", "list", "--porcelain"], + )?) { + let Ok(info) = info_from_existing_worktree( + &query.codex_home, + worktree.path.as_path(), + worktree.branch.clone(), + worktree + .branch + .as_deref() + .and_then(|branch| paths::slugify_name(branch).ok()), + ) else { + continue; + }; + if worktree_matches_repo(&info, repo_filter) { + entries.push(info); + } + } + } + + let root = paths::codex_worktrees_root(&query.codex_home); + if root.exists() { + for worktree_root in discover_codex_home_worktree_roots(&root)? { + let Ok(info) = info_from_existing_worktree( + &query.codex_home, + worktree_root.as_path(), + /*fallback_name*/ None, + /*fallback_slug*/ None, + ) else { + continue; + }; + if let Some(repo_filter) = repo_filter.as_ref() + && !worktree_matches_repo(&info, repo_filter) + { + continue; + } + entries.push(info); + } + } + let mut unique_entries = Vec::new(); + for entry in entries { + if unique_entries.iter().any(|existing: &WorktreeInfo| { + paths_match(&existing.worktree_git_root, &entry.worktree_git_root) + }) { + continue; + } + unique_entries.push(entry); + } + let mut entries = unique_entries; + entries.sort_by(|a, b| { + display_branch_or_name(a) + .cmp(display_branch_or_name(b)) + .then_with(|| a.worktree_git_root.cmp(&b.worktree_git_root)) + }); + Ok(entries) +} + +fn discover_codex_home_worktree_roots(root: &Path) -> Result> { + let mut roots = Vec::new(); + for parent in fs::read_dir(root)? { + let parent = parent?; + if !parent.file_type()?.is_dir() { + continue; + } + let parent_path = parent.path(); + if is_git_root(&parent_path) { + roots.push(parent_path); + continue; + } + for child in fs::read_dir(parent_path)? { + let child = child?; + if !child.file_type()?.is_dir() { + continue; + } + let child_path = child.path(); + if is_git_root(&child_path) { + roots.push(child_path); + continue; + } + for grandchild in fs::read_dir(child_path)? { + let grandchild = grandchild?; + if !grandchild.file_type()?.is_dir() { + continue; + } + let grandchild_path = grandchild.path(); + if is_git_root(&grandchild_path) { + roots.push(grandchild_path); + } + } + } + } + roots.sort(); + roots.dedup(); + Ok(roots) +} + +fn is_git_root(path: &Path) -> bool { + path.join(".git").exists() +} + +fn worktree_matches_repo(info: &WorktreeInfo, repo: &SourceRepo) -> bool { + info.id == repo.id || paths_match(&info.common_git_dir, &repo.common_git_dir) +} + +fn paths_match(a: &Path, b: &Path) -> bool { + let a = a.canonicalize().unwrap_or_else(|_| a.to_path_buf()); + let b = b.canonicalize().unwrap_or_else(|_| b.to_path_buf()); + a == b +} + +pub fn remove_worktree(req: WorktreeRemoveRequest) -> Result { + let target = target_worktree_path(&req)?; + let metadata = metadata::read_worktree_metadata(&target)? + .context("refusing to remove a worktree not managed by Codex")?; + let dirty = dirty::dirty_state(&target)?; + if dirty.is_dirty() && !req.force { + anyhow::bail!( + "refusing to remove dirty worktree {}; use --force to override", + target.display() + ); + } + let branch = current_branch(&target)?; + let mut args = vec!["worktree", "remove"]; + if req.force { + args.push("--force"); + } + let target_arg = target.to_string_lossy(); + args.push(&target_arg); + let primary_root = primary_worktree_root(&target)?; + git::status(&primary_root, &args)?; + + let mut deleted_branch = None; + if req.delete_branch + && let Some(branch) = branch + { + if req.force { + git::status(&primary_root, &["branch", "-D", &branch])?; + } else { + git::status(&primary_root, &["branch", "-d", &branch])?; + } + deleted_branch = Some(branch); + } + + if metadata.location == WorktreeLocation::CodexHome + && let Some(parent) = metadata.worktree_git_root.parent() + && parent.exists() + && parent.read_dir()?.next().is_none() + { + fs::remove_dir(parent)?; + } + + Ok(WorktreeRemoveResult { + removed_path: target, + deleted_branch, + }) +} + +fn ensure_existing_worktree_matches_branch( + worktree_git_root: &Path, + metadata: &WorktreeMetadata, + requested_branch: &str, +) -> Result<()> { + if metadata.branch.as_deref() == Some(requested_branch) || metadata.name == requested_branch { + return Ok(()); + } + if current_branch(worktree_git_root)?.as_deref() == Some(requested_branch) { + return Ok(()); + } + anyhow::bail!( + "managed worktree path {} is already used by {}; choose a different branch name", + worktree_git_root.display(), + metadata.branch.as_deref().unwrap_or(metadata.name.as_str()) + ) +} + +fn target_worktree_path(req: &WorktreeRemoveRequest) -> Result { + let raw = PathBuf::from(&req.name_or_path); + if raw.is_absolute() { + return Ok(raw); + } + let entries = list_worktrees(WorktreeListQuery { + codex_home: req.codex_home.clone(), + source_cwd: req.source_cwd.clone(), + include_all_repos: req.source_cwd.is_none(), + })?; + let matches = entries + .into_iter() + .filter(|entry| { + entry.branch.as_deref() == Some(req.name_or_path.as_str()) + || entry.name == req.name_or_path + || entry.slug == req.name_or_path + }) + .collect::>(); + match matches.as_slice() { + [entry] => Ok(entry.worktree_git_root.clone()), + [] => anyhow::bail!("no managed worktree named {}", req.name_or_path), + _ => anyhow::bail!( + "multiple managed worktrees named {}; pass a path instead", + req.name_or_path + ), + } +} + +fn info_from_existing_worktree( + codex_home: &Path, + worktree_git_root: &Path, + fallback_name: Option, + fallback_slug: Option, +) -> Result { + let metadata = metadata::read_worktree_metadata(worktree_git_root)?; + let root = git::stdout(worktree_git_root, &["rev-parse", "--show-toplevel"]) + .map(PathBuf::from) + .unwrap_or_else(|_| worktree_git_root.to_path_buf()); + let common_git_dir = git::stdout(worktree_git_root, &["rev-parse", "--git-common-dir"]) + .map(|value| absolutize(worktree_git_root, Path::new(&value))) + .unwrap_or_else(|_| PathBuf::new()); + let branch = current_branch(worktree_git_root)?; + let head = git::stdout(worktree_git_root, &["rev-parse", "HEAD"]).ok(); + let dirty = dirty::dirty_state(worktree_git_root).unwrap_or_default(); + let (source, location) = classify_worktree(codex_home, worktree_git_root, metadata.as_ref()); + let repo_name = root + .file_name() + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_else(|| "repo".to_string()); + let id = metadata + .as_ref() + .map(|metadata| metadata.repo_id.clone()) + .unwrap_or_else(|| { + root.strip_prefix(paths::codex_worktrees_root(codex_home)) + .ok() + .and_then(|path| path.components().next()) + .map(|component| component.as_os_str().to_string_lossy().to_string()) + .unwrap_or_default() + }); + let name = metadata + .as_ref() + .map(|metadata| metadata.name.clone()) + .or(fallback_name) + .or_else(|| branch.clone()) + .unwrap_or_else(|| repo_name.clone()); + let slug = metadata + .as_ref() + .map(|metadata| metadata.slug.clone()) + .or(fallback_slug) + .unwrap_or_else(|| paths::slugify_name(&name).unwrap_or_else(|_| name.clone())); + let workspace_cwd = metadata + .as_ref() + .map(|metadata| metadata.workspace_cwd.clone()) + .unwrap_or_else(|| root.clone()); + let original_relative_cwd = metadata + .as_ref() + .map(|metadata| metadata.original_relative_cwd.clone()) + .unwrap_or_default(); + Ok(WorktreeInfo { + id, + name, + slug, + source, + location, + repo_name, + repo_root: root, + common_git_dir, + worktree_git_root: worktree_git_root.to_path_buf(), + workspace_cwd, + original_relative_cwd, + branch, + head, + owner_thread_id: metadata.and_then(|metadata| metadata.owner_thread_id), + metadata_path: metadata_path_for_display(worktree_git_root)?, + dirty, + }) +} + +fn classify_worktree( + codex_home: &Path, + worktree_git_root: &Path, + metadata: Option<&WorktreeMetadata>, +) -> (WorktreeSource, WorktreeLocation) { + if let Some(metadata) = metadata { + return (metadata.source, metadata.location); + } + if paths::is_managed_worktree_path(worktree_git_root, codex_home) { + return (WorktreeSource::App, WorktreeLocation::CodexHome); + } + (WorktreeSource::Git, WorktreeLocation::External) +} + +fn display_branch_or_name(info: &WorktreeInfo) -> &str { + info.branch.as_deref().unwrap_or(&info.name) +} + +struct SourceRepo { + root: PathBuf, + relative_cwd: PathBuf, + common_git_dir: PathBuf, + repo_name: String, + id: String, +} + +impl SourceRepo { + fn resolve(source_cwd: &Path) -> Result { + let source_cwd = source_cwd + .canonicalize() + .unwrap_or_else(|_| source_cwd.to_path_buf()); + let root = PathBuf::from(git::stdout(&source_cwd, &["rev-parse", "--show-toplevel"])?); + let root = root.canonicalize().unwrap_or(root); + let common_git_dir_raw = git::stdout(&source_cwd, &["rev-parse", "--git-common-dir"])?; + let common_git_dir = absolutize(&source_cwd, Path::new(&common_git_dir_raw)) + .canonicalize() + .unwrap_or_else(|_| absolutize(&source_cwd, Path::new(&common_git_dir_raw))); + let origin = git::stdout(&root, &["remote", "get-url", "origin"]).ok(); + let id = paths::repo_fingerprint(&common_git_dir, origin.as_deref()); + let repo_name = root + .file_name() + .context("repository root has no directory name")? + .to_string_lossy() + .to_string(); + let relative_cwd = source_cwd + .strip_prefix(&root) + .unwrap_or_else(|_| Path::new("")) + .to_path_buf(); + Ok(Self { + root, + relative_cwd, + common_git_dir, + repo_name, + id, + }) + } +} + +fn branch_checkout_path(root: &Path, branch: &str) -> Result> { + let worktrees = parse_worktree_list(&git::stdout(root, &["worktree", "list", "--porcelain"])?); + Ok(worktrees + .into_iter() + .find_map(|entry| (entry.branch.as_deref() == Some(branch)).then_some(entry.path))) +} + +fn branch_exists(root: &Path, branch: &str) -> bool { + git::status( + root, + &[ + "show-ref", + "--verify", + "--quiet", + &format!("refs/heads/{branch}"), + ], + ) + .is_ok() +} + +fn ensure_safe_branch_name(root: &Path, branch: &str) -> Result<()> { + if branch.trim().is_empty() { + anyhow::bail!("branch name must not be empty"); + } + git::status(root, &["check-ref-format", "--branch", branch]).context("invalid branch name") +} + +fn current_branch(root: &Path) -> Result> { + let output = std::process::Command::new("git") + .args(["symbolic-ref", "--quiet", "--short", "HEAD"]) + .current_dir(root) + .output()?; + if output.status.success() { + let branch = String::from_utf8(output.stdout)?.trim().to_string(); + Ok((!branch.is_empty()).then_some(branch)) + } else { + Ok(None) + } +} + +fn primary_worktree_root(root: &Path) -> Result { + let worktrees = parse_worktree_list(&git::stdout(root, &["worktree", "list", "--porcelain"])?); + worktrees + .into_iter() + .next() + .map(|entry| entry.path) + .context("git did not report a primary worktree") +} + +fn metadata_path_for_display(worktree_path: &Path) -> Result { + let path = git::stdout( + worktree_path, + &["rev-parse", "--git-path", "codex-worktree.json"], + )?; + Ok(absolutize(worktree_path, Path::new(&path))) +} + +fn absolutize(cwd: &Path, path: &Path) -> PathBuf { + if path.is_absolute() { + path.to_path_buf() + } else { + cwd.join(path) + } +} + +#[derive(Debug, PartialEq, Eq)] +struct GitWorktreeEntry { + path: PathBuf, + branch: Option, +} + +fn parse_worktree_list(output: &str) -> Vec { + let mut entries = Vec::new(); + let mut path = None; + let mut branch = None; + for line in output.lines().chain(std::iter::once("")) { + if line.is_empty() { + if let Some(path) = path.take() { + entries.push(GitWorktreeEntry { + path, + branch: branch.take(), + }); + } + continue; + } + if let Some(raw_path) = line.strip_prefix("worktree ") { + path = Some(PathBuf::from(raw_path)); + } else if let Some(raw_branch) = line.strip_prefix("branch ") { + branch = Some(raw_branch.trim_start_matches("refs/heads/").to_string()); + } + } + entries +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn parse_worktree_list_preserves_branches() { + let entries = parse_worktree_list( + "worktree /repo\nHEAD abc\nbranch refs/heads/main\n\nworktree /repo.wt\nHEAD def\nbranch refs/heads/codex/demo\n\n", + ); + assert_eq!( + entries, + vec![ + GitWorktreeEntry { + path: PathBuf::from("/repo"), + branch: Some("main".to_string()) + }, + GitWorktreeEntry { + path: PathBuf::from("/repo.wt"), + branch: Some("codex/demo".to_string()) + } + ] + ); + } +} diff --git a/codex-rs/worktree/src/metadata.rs b/codex-rs/worktree/src/metadata.rs new file mode 100644 index 000000000000..f2b09308cd9d --- /dev/null +++ b/codex-rs/worktree/src/metadata.rs @@ -0,0 +1,157 @@ +use std::fs; +use std::path::Path; +use std::path::PathBuf; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; + +use anyhow::Result; +use serde::Deserialize; +use serde::Serialize; + +use crate::WorktreeInfo; +use crate::WorktreeLocation; +use crate::WorktreeSource; +use crate::git; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorktreeThreadMetadata { + pub version: u32, + #[serde(skip_serializing_if = "Option::is_none")] + pub owner_thread_id: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorktreeMetadata { + pub version: u32, + pub manager: String, + pub backend: String, + #[serde(default = "default_source")] + pub source: WorktreeSource, + #[serde(default = "default_location")] + pub location: WorktreeLocation, + pub id: String, + pub name: String, + pub slug: String, + pub branch: Option, + pub repo_id: String, + pub repo_name: String, + pub source_repo_root: PathBuf, + pub original_relative_cwd: PathBuf, + pub worktree_git_root: PathBuf, + pub workspace_cwd: PathBuf, + pub created_at: i64, + pub updated_at: i64, + pub owner_thread_id: Option, + pub tmux_session: Option, +} + +impl WorktreeMetadata { + pub fn from_info(info: &WorktreeInfo, source_repo_root: PathBuf) -> Self { + let now = unix_seconds(); + Self { + version: 1, + manager: "codex-cli".to_string(), + backend: "git".to_string(), + source: info.source, + location: info.location, + id: info.id.clone(), + name: info.name.clone(), + slug: info.slug.clone(), + branch: info.branch.clone(), + repo_id: info.id.clone(), + repo_name: info.repo_name.clone(), + source_repo_root, + original_relative_cwd: info.original_relative_cwd.clone(), + worktree_git_root: info.worktree_git_root.clone(), + workspace_cwd: info.workspace_cwd.clone(), + created_at: now, + updated_at: now, + owner_thread_id: info.owner_thread_id.clone(), + tmux_session: None, + } + } +} + +fn default_source() -> WorktreeSource { + WorktreeSource::Legacy +} + +fn default_location() -> WorktreeLocation { + WorktreeLocation::CodexHome +} + +pub fn read_worktree_metadata(worktree_path: &Path) -> Result> { + let path = metadata_path(worktree_path, "codex-worktree.json")?; + read_json_if_exists(&path) +} + +pub fn write_worktree_metadata(worktree_path: &Path, metadata: &WorktreeMetadata) -> Result<()> { + let path = metadata_path(worktree_path, "codex-worktree.json")?; + write_json(&path, metadata) +} + +pub fn bind_thread(workspace_cwd: &Path, thread_id: &str) -> Result<()> { + let git_root = git::stdout(workspace_cwd, &["rev-parse", "--show-toplevel"])?; + let git_root = PathBuf::from(git_root); + let owner = WorktreeThreadMetadata { + version: 1, + owner_thread_id: Some(thread_id.to_string()), + }; + let owner_path = metadata_path(&git_root, "codex-thread.json")?; + write_json(&owner_path, &owner)?; + + if let Some(mut metadata) = read_worktree_metadata(&git_root)? { + metadata.owner_thread_id = Some(thread_id.to_string()); + metadata.updated_at = unix_seconds(); + write_worktree_metadata(&git_root, &metadata)?; + } + Ok(()) +} + +pub fn write_pending_owner_metadata(worktree_path: &Path) -> Result<()> { + let metadata = WorktreeThreadMetadata { + version: 1, + owner_thread_id: None, + }; + let path = metadata_path(worktree_path, "codex-thread.json")?; + write_json(&path, &metadata) +} + +fn read_json_if_exists(path: &Path) -> Result> +where + T: serde::de::DeserializeOwned, +{ + if !path.exists() { + return Ok(None); + } + let contents = fs::read_to_string(path)?; + Ok(Some(serde_json::from_str(&contents)?)) +} + +fn write_json(path: &Path, value: &T) -> Result<()> +where + T: serde::Serialize, +{ + let contents = serde_json::to_string_pretty(value)?; + fs::write(path, contents)?; + Ok(()) +} + +fn metadata_path(worktree_path: &Path, name: &str) -> Result { + let path = git::stdout(worktree_path, &["rev-parse", "--git-path", name])?; + let path = PathBuf::from(path); + Ok(if path.is_absolute() { + path + } else { + worktree_path.join(path) + }) +} + +fn unix_seconds() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs() as i64) + .unwrap_or_default() +} diff --git a/codex-rs/worktree/src/paths.rs b/codex-rs/worktree/src/paths.rs new file mode 100644 index 000000000000..c99b7565fc73 --- /dev/null +++ b/codex-rs/worktree/src/paths.rs @@ -0,0 +1,105 @@ +use std::path::Path; +use std::path::PathBuf; + +use anyhow::Context; +use anyhow::Result; +use sha2::Digest; + +pub fn codex_worktrees_root(codex_home: &Path) -> PathBuf { + codex_home.join("worktrees") +} + +pub fn is_managed_worktree_path(path: &Path, codex_home: &Path) -> bool { + let path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf()); + let root = codex_worktrees_root(codex_home) + .canonicalize() + .unwrap_or_else(|_| codex_worktrees_root(codex_home)); + path.starts_with(root) +} + +pub fn slugify_name(name: &str) -> Result { + let slug = name + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() { + ch.to_ascii_lowercase() + } else { + '-' + } + }) + .collect::() + .split('-') + .filter(|part| !part.is_empty()) + .take(12) + .collect::>() + .join("-"); + if slug.is_empty() { + anyhow::bail!("worktree name must contain at least one ASCII letter or digit"); + } + Ok(slug) +} + +pub fn sanitize_branch_for_path(branch: &str) -> Result { + let sanitized = branch.replace(['/', '\\'], "-"); + if sanitized.trim().is_empty() { + anyhow::bail!("branch name must produce a non-empty worktree path segment"); + } + Ok(sanitized) +} + +pub fn repo_fingerprint(common_git_dir: &Path, origin_url: Option<&str>) -> String { + let mut hasher = sha2::Sha256::new(); + hasher.update(common_git_dir.to_string_lossy().as_bytes()); + if let Some(origin_url) = origin_url { + hasher.update(b"\0"); + hasher.update(origin_url.as_bytes()); + } + let digest = hasher.finalize(); + digest + .iter() + .take(8) + .map(|byte| format!("{byte:02x}")) + .collect() +} + +pub fn sibling_worktree_git_root(repo_root: &Path, branch: &str) -> Result { + let repo_name = repo_root + .file_name() + .context("source repository root has no directory name")?; + let parent = repo_root + .parent() + .context("source repository root has no parent directory")?; + let sanitized_branch = sanitize_branch_for_path(branch)?; + let dirname = format!("{}.{}", repo_name.to_string_lossy(), sanitized_branch); + Ok(parent.join(dirname)) +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn slugify_name_keeps_short_ascii_slug() -> Result<()> { + assert_eq!(slugify_name("Fix parser tests!")?, "fix-parser-tests"); + Ok(()) + } + + #[test] + fn sanitize_branch_for_path_matches_worktrunk_style() -> Result<()> { + assert_eq!( + sanitize_branch_for_path("feature/auth\\windows")?, + "feature-auth-windows" + ); + Ok(()) + } + + #[test] + fn sibling_worktree_path_matches_worktrunk_default() -> Result<()> { + assert_eq!( + sibling_worktree_git_root(Path::new("/Users/me/code/codex"), "feature/auth")?, + PathBuf::from("/Users/me/code/codex.feature-auth") + ); + Ok(()) + } +} diff --git a/codex-rs/worktree/tests/git_backend.rs b/codex-rs/worktree/tests/git_backend.rs new file mode 100644 index 000000000000..6f9bbc4cdccb --- /dev/null +++ b/codex-rs/worktree/tests/git_backend.rs @@ -0,0 +1,239 @@ +use std::fs; +use std::path::Path; +use std::process::Command; + +use codex_worktree::DirtyPolicy; +use codex_worktree::WorktreeListQuery; +use codex_worktree::WorktreeLocation; +use codex_worktree::WorktreeRemoveRequest; +use codex_worktree::WorktreeRequest; +use codex_worktree::WorktreeSource; +use pretty_assertions::assert_eq; +use tempfile::TempDir; + +#[test] +fn creates_reuses_lists_and_removes_managed_worktree() -> anyhow::Result<()> { + let fixture = GitFixture::new()?; + fs::create_dir(fixture.repo.path().join("codex-rs"))?; + fs::write(fixture.repo.path().join("codex-rs/README.md"), "subdir\n")?; + run_git(fixture.repo.path(), &["add", "codex-rs/README.md"])?; + run_git(fixture.repo.path(), &["commit", "-m", "add subdir"])?; + + let resolution = codex_worktree::ensure_worktree(WorktreeRequest { + codex_home: fixture.codex_home.path().to_path_buf(), + source_cwd: fixture.repo.path().join("codex-rs"), + branch: "parser-fix".to_string(), + base_ref: None, + dirty_policy: DirtyPolicy::Fail, + })?; + + assert!(!resolution.reused); + assert_eq!(resolution.info.name, "parser-fix"); + assert_eq!(resolution.info.slug, "parser-fix"); + assert_eq!(resolution.info.branch.as_deref(), Some("parser-fix")); + assert_eq!(resolution.info.source, WorktreeSource::Cli); + assert_eq!(resolution.info.location, WorktreeLocation::Sibling); + let canonical_repo = fixture.repo.path().canonicalize()?; + assert_eq!( + resolution.info.worktree_git_root, + canonical_repo.with_file_name(format!( + "{}.parser-fix", + canonical_repo.file_name().unwrap().to_string_lossy() + )) + ); + assert_eq!( + resolution.info.workspace_cwd, + resolution.info.worktree_git_root.join("codex-rs") + ); + assert!(resolution.info.workspace_cwd.exists()); + assert!( + git( + &resolution.info.worktree_git_root, + &["rev-parse", "--git-path", "codex-worktree.json"] + ) + .map(|path| resolution.info.worktree_git_root.join(path).exists()) + .unwrap_or(false) + ); + + let reused = codex_worktree::ensure_worktree(WorktreeRequest { + codex_home: fixture.codex_home.path().to_path_buf(), + source_cwd: fixture.repo.path().join("codex-rs"), + branch: "parser-fix".to_string(), + base_ref: None, + dirty_policy: DirtyPolicy::Fail, + })?; + assert!(reused.reused); + assert_eq!( + reused.info.worktree_git_root, + resolution.info.worktree_git_root + ); + + let entries = codex_worktree::list_worktrees(WorktreeListQuery { + codex_home: fixture.codex_home.path().to_path_buf(), + source_cwd: Some(fixture.repo.path().to_path_buf()), + include_all_repos: false, + })?; + assert_eq!( + entries + .iter() + .filter(|entry| entry.source == WorktreeSource::Cli) + .map(|entry| entry.branch.as_deref()) + .collect::>(), + vec![Some("parser-fix")] + ); + + let removed = codex_worktree::remove_worktree(WorktreeRemoveRequest { + codex_home: fixture.codex_home.path().to_path_buf(), + source_cwd: Some(fixture.repo.path().to_path_buf()), + name_or_path: "parser-fix".to_string(), + force: false, + delete_branch: false, + })?; + assert_eq!(removed.removed_path, resolution.info.worktree_git_root); + assert!(!removed.removed_path.exists()); + Ok(()) +} + +#[test] +fn copy_tracked_preserves_staged_and_unstaged_diffs() -> anyhow::Result<()> { + let fixture = GitFixture::new()?; + fs::write(fixture.repo.path().join("staged.txt"), "staged changed\n")?; + run_git(fixture.repo.path(), &["add", "staged.txt"])?; + fs::write( + fixture.repo.path().join("unstaged.txt"), + "unstaged changed\n", + )?; + + let resolution = codex_worktree::ensure_worktree(WorktreeRequest { + codex_home: fixture.codex_home.path().to_path_buf(), + source_cwd: fixture.repo.path().to_path_buf(), + branch: "copy-dirty".to_string(), + base_ref: None, + dirty_policy: DirtyPolicy::CopyTracked, + })?; + + assert_eq!( + git( + &resolution.info.worktree_git_root, + &["diff", "--cached", "--name-only"] + )?, + "staged.txt" + ); + assert_eq!( + git(&resolution.info.worktree_git_root, &["diff", "--name-only"])?, + "unstaged.txt" + ); + Ok(()) +} + +#[test] +fn refuses_sibling_path_collision_for_different_branch() -> anyhow::Result<()> { + let fixture = GitFixture::new()?; + let resolution = codex_worktree::ensure_worktree(WorktreeRequest { + codex_home: fixture.codex_home.path().to_path_buf(), + source_cwd: fixture.repo.path().to_path_buf(), + branch: "feature/foo".to_string(), + base_ref: None, + dirty_policy: DirtyPolicy::Fail, + })?; + + let err = codex_worktree::ensure_worktree(WorktreeRequest { + codex_home: fixture.codex_home.path().to_path_buf(), + source_cwd: fixture.repo.path().to_path_buf(), + branch: "feature-foo".to_string(), + base_ref: None, + dirty_policy: DirtyPolicy::Fail, + }) + .expect_err("sanitized branch path collision should fail"); + + assert!( + err.to_string().contains("already used by feature/foo"), + "{err:#}" + ); + let removed = codex_worktree::remove_worktree(WorktreeRemoveRequest { + codex_home: fixture.codex_home.path().to_path_buf(), + source_cwd: Some(fixture.repo.path().to_path_buf()), + name_or_path: "feature/foo".to_string(), + force: false, + delete_branch: false, + })?; + assert_eq!(removed.removed_path, resolution.info.worktree_git_root); + Ok(()) +} + +#[test] +fn list_includes_app_style_worktrees_without_cli_metadata() -> anyhow::Result<()> { + let fixture = GitFixture::new()?; + let app_worktree = fixture.codex_home.path().join("worktrees/7f6e/repo"); + fs::create_dir_all(app_worktree.parent().expect("app worktree parent"))?; + run_git( + fixture.repo.path(), + &[ + "worktree", + "add", + app_worktree.to_str().expect("utf-8 path"), + "HEAD", + ], + )?; + + let entries = codex_worktree::list_worktrees(WorktreeListQuery { + codex_home: fixture.codex_home.path().to_path_buf(), + source_cwd: Some(fixture.repo.path().to_path_buf()), + include_all_repos: false, + })?; + + let canonical_app_worktree = app_worktree.canonicalize()?; + assert_eq!( + entries + .iter() + .filter(|entry| entry.source == WorktreeSource::App) + .map(|entry| (entry.name.as_str(), entry.worktree_git_root.as_path())) + .collect::>(), + vec![("repo", canonical_app_worktree.as_path())] + ); + Ok(()) +} + +struct GitFixture { + repo: TempDir, + codex_home: TempDir, +} + +impl GitFixture { + fn new() -> anyhow::Result { + let repo = TempDir::new()?; + let codex_home = TempDir::new()?; + run_git(repo.path(), &["init", "-b", "main"])?; + run_git(repo.path(), &["config", "user.email", "codex@example.com"])?; + run_git(repo.path(), &["config", "user.name", "Codex"])?; + fs::write(repo.path().join("staged.txt"), "staged original\n")?; + fs::write(repo.path().join("unstaged.txt"), "unstaged original\n")?; + run_git(repo.path(), &["add", "."])?; + run_git(repo.path(), &["commit", "-m", "initial"])?; + Ok(Self { repo, codex_home }) + } +} + +fn run_git(cwd: &Path, args: &[&str]) -> anyhow::Result<()> { + let output = Command::new("git").args(args).current_dir(cwd).output()?; + if !output.status.success() { + anyhow::bail!( + "git {} failed: {}", + args.join(" "), + String::from_utf8_lossy(&output.stderr) + ); + } + Ok(()) +} + +fn git(cwd: &Path, args: &[&str]) -> anyhow::Result { + let output = Command::new("git").args(args).current_dir(cwd).output()?; + if !output.status.success() { + anyhow::bail!( + "git {} failed: {}", + args.join(" "), + String::from_utf8_lossy(&output.stderr) + ); + } + Ok(String::from_utf8(output.stdout)?.trim_end().to_string()) +} From 250390cb76801e1c2c04a4666be7fcd3a0e97d3f Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Wed, 6 May 2026 21:07:37 -0300 Subject: [PATCH 02/12] feat(tui): add worktree slash command --- codex-rs/tui/src/app.rs | 1 + codex-rs/tui/src/app/event_dispatch.rs | 35 + codex-rs/tui/src/app/worktree.rs | 352 +++++++++ codex-rs/tui/src/app_event.rs | 35 + codex-rs/tui/src/chatwidget.rs | 15 + codex-rs/tui/src/chatwidget/slash_dispatch.rs | 11 + codex-rs/tui/src/lib.rs | 1 + codex-rs/tui/src/slash_command.rs | 4 + ...__tests__worktree_dirty_policy_prompt.snap | 14 + ...ui__worktree__tests__worktree_loading.snap | 13 + ...tui__worktree__tests__worktree_picker.snap | 14 + ...__tests__worktree_remove_confirmation.snap | 12 + ...__worktree__tests__worktree_switching.snap | 13 + codex-rs/tui/src/worktree.rs | 708 ++++++++++++++++++ codex-rs/worktree/src/lib.rs | 1 + 15 files changed, 1229 insertions(+) create mode 100644 codex-rs/tui/src/app/worktree.rs create mode 100644 codex-rs/tui/src/snapshots/codex_tui__worktree__tests__worktree_dirty_policy_prompt.snap create mode 100644 codex-rs/tui/src/snapshots/codex_tui__worktree__tests__worktree_loading.snap create mode 100644 codex-rs/tui/src/snapshots/codex_tui__worktree__tests__worktree_picker.snap create mode 100644 codex-rs/tui/src/snapshots/codex_tui__worktree__tests__worktree_remove_confirmation.snap create mode 100644 codex-rs/tui/src/snapshots/codex_tui__worktree__tests__worktree_switching.snap create mode 100644 codex-rs/tui/src/worktree.rs diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 176663058ae5..b50541f9c266 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -201,6 +201,7 @@ mod thread_events; mod thread_goal_actions; mod thread_routing; mod thread_session_state; +mod worktree; use self::agent_navigation::AgentNavigationDirection; use self::agent_navigation::AgentNavigationState; diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index bfe8dc4b240c..892b3734e1a9 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -187,6 +187,41 @@ impl App { tui.frame_requester().schedule_frame(); } + AppEvent::OpenWorktreePicker => { + self.open_worktree_picker(tui); + tui.frame_requester().schedule_frame(); + } + AppEvent::WorktreesLoaded { cwd, result } => { + self.on_worktrees_loaded(cwd, result); + tui.frame_requester().schedule_frame(); + } + AppEvent::CreateWorktreeAndSwitch { + branch, + base_ref, + dirty_policy, + } => { + self.create_worktree_and_switch(tui, app_server, branch, base_ref, dirty_policy) + .await; + tui.frame_requester().schedule_frame(); + } + AppEvent::SwitchToWorktree { target } => { + self.switch_to_worktree_target(tui, app_server, target) + .await; + tui.frame_requester().schedule_frame(); + } + AppEvent::ShowWorktreePath { target } => { + self.show_worktree_path(target); + tui.frame_requester().schedule_frame(); + } + AppEvent::RemoveWorktree { + target, + force, + delete_branch, + confirmed, + } => { + self.remove_worktree(target, force, delete_branch, confirmed); + tui.frame_requester().schedule_frame(); + } AppEvent::BeginInitialHistoryReplayBuffer => { self.begin_initial_history_replay_buffer(); } diff --git a/codex-rs/tui/src/app/worktree.rs b/codex-rs/tui/src/app/worktree.rs new file mode 100644 index 000000000000..11d689b0d6dd --- /dev/null +++ b/codex-rs/tui/src/app/worktree.rs @@ -0,0 +1,352 @@ +//! App-layer handlers for the worktree TUI flow. + +use codex_worktree::DirtyPolicy; +use codex_worktree::WorktreeInfo; +use codex_worktree::WorktreeListQuery; +use codex_worktree::WorktreeRemoveRequest; +use codex_worktree::WorktreeRequest; +use codex_worktree::WorktreeSource; +use std::path::PathBuf; + +use super::*; + +impl App { + pub(super) fn open_worktree_picker(&mut self, tui: &mut tui::Tui) { + if self.remote_app_server_url.is_some() { + self.chat_widget.add_error_message( + "/worktree is not supported for remote sessions yet.".to_string(), + ); + return; + } + self.chat_widget + .show_selection_view(crate::worktree::loading_params( + tui.frame_requester(), + self.config.animations, + )); + self.fetch_worktrees_for_picker(); + } + + pub(super) fn on_worktrees_loaded( + &mut self, + cwd: PathBuf, + result: Result, String>, + ) { + if cwd.as_path() != self.config.cwd.as_path() { + return; + } + let params = match result { + Ok(entries) if entries.is_empty() => crate::worktree::empty_params(), + Ok(entries) => crate::worktree::picker_params(entries, self.config.cwd.as_path()), + Err(err) => crate::worktree::error_params(err), + }; + self.replace_worktree_view(params); + } + + pub(super) async fn create_worktree_and_switch( + &mut self, + tui: &mut tui::Tui, + app_server: &mut AppServerSession, + branch: String, + base_ref: Option, + dirty_policy: Option, + ) { + if self.remote_app_server_url.is_some() { + self.chat_widget.add_error_message( + "/worktree is not supported for remote sessions yet.".to_string(), + ); + return; + } + let dirty_policy = match dirty_policy { + Some(policy) => policy, + None => match codex_worktree::dirty_state(self.config.cwd.as_path()) { + Ok(state) if state.is_dirty() => { + let params = crate::worktree::dirty_policy_prompt_params(branch, base_ref); + self.chat_widget.show_selection_view(params); + return; + } + Ok(_) => DirtyPolicy::Fail, + Err(err) => { + self.chat_widget + .add_error_message(format!("Failed to inspect source checkout: {err}")); + return; + } + }, + }; + + let resolution = match codex_worktree::ensure_worktree(WorktreeRequest { + codex_home: self.config.codex_home.to_path_buf(), + source_cwd: self.config.cwd.to_path_buf(), + branch, + base_ref, + dirty_policy, + }) { + Ok(resolution) => resolution, + Err(err) => { + self.chat_widget + .add_error_message(format!("Failed to create worktree: {err}")); + return; + } + }; + let warnings = resolution + .warnings + .iter() + .map(|warning| warning.message.clone()) + .collect::>(); + let target = resolution + .info + .branch + .clone() + .unwrap_or_else(|| resolution.info.name.clone()); + self.show_worktree_switching_view(tui, target).await; + self.switch_to_worktree_info(tui, app_server, resolution.info) + .await; + for warning in warnings { + self.chat_widget.add_info_message(warning, /*hint*/ None); + } + } + + pub(super) async fn switch_to_worktree_target( + &mut self, + tui: &mut tui::Tui, + app_server: &mut AppServerSession, + target: String, + ) { + if self.remote_app_server_url.is_some() { + self.chat_widget.add_error_message( + "/worktree is not supported for remote sessions yet.".to_string(), + ); + return; + } + self.show_worktree_switching_view(tui, target.clone()).await; + let entries = match self.list_current_repo_worktrees() { + Ok(entries) => entries, + Err(err) => { + self.show_worktree_error("Failed to list worktrees.".to_string(), err.to_string()); + return; + } + }; + let info = match crate::worktree::find_worktree(&entries, &target) { + Ok(info) => info.clone(), + Err(err) => { + self.show_worktree_error("Failed to switch worktree.".to_string(), err); + return; + } + }; + self.switch_to_worktree_info(tui, app_server, info).await; + } + + pub(super) fn show_worktree_path(&mut self, target: String) { + if self.remote_app_server_url.is_some() { + self.chat_widget.add_error_message( + "/worktree is not supported for remote sessions yet.".to_string(), + ); + return; + } + match self.list_current_repo_worktrees() { + Ok(entries) => match crate::worktree::find_worktree(&entries, &target) { + Ok(info) => { + self.chat_widget.add_info_message( + info.workspace_cwd.display().to_string(), + /*hint*/ None, + ); + } + Err(err) => self.chat_widget.add_error_message(err), + }, + Err(err) => { + self.chat_widget + .add_error_message(format!("Failed to list worktrees: {err}")); + } + } + } + + pub(super) fn remove_worktree( + &mut self, + target: String, + force: bool, + delete_branch: bool, + confirmed: bool, + ) { + if self.remote_app_server_url.is_some() { + self.chat_widget.add_error_message( + "/worktree is not supported for remote sessions yet.".to_string(), + ); + return; + } + let entries = match self.list_current_repo_worktrees() { + Ok(entries) => entries, + Err(err) => { + self.chat_widget + .add_error_message(format!("Failed to list worktrees: {err}")); + return; + } + }; + let info = match crate::worktree::find_worktree(&entries, &target) { + Ok(info) => info, + Err(err) => { + self.chat_widget.add_error_message(err); + return; + } + }; + if info.source != WorktreeSource::Cli { + let source = crate::worktree::source_label(info.source); + self.chat_widget.add_error_message(format!( + "Refusing to remove {source} worktree '{target}'. Only Codex CLI-managed worktrees can be removed." + )); + return; + } + if !confirmed { + let params = crate::worktree::remove_confirmation_params(target, force, delete_branch); + self.chat_widget.show_selection_view(params); + return; + } + + match codex_worktree::remove_worktree(WorktreeRemoveRequest { + codex_home: self.config.codex_home.to_path_buf(), + source_cwd: Some(self.config.cwd.to_path_buf()), + name_or_path: target.clone(), + force, + delete_branch, + }) { + Ok(result) => { + let mut message = format!("Removed worktree {}", result.removed_path.display()); + if let Some(branch) = result.deleted_branch { + message.push_str(&format!(" and deleted branch {branch}")); + } + self.chat_widget.add_info_message(message, /*hint*/ None); + } + Err(err) => { + self.chat_widget + .add_error_message(format!("Failed to remove worktree: {err}")); + } + } + } + + fn list_current_repo_worktrees(&self) -> anyhow::Result> { + codex_worktree::list_worktrees(WorktreeListQuery { + codex_home: self.config.codex_home.to_path_buf(), + source_cwd: Some(self.config.cwd.to_path_buf()), + include_all_repos: false, + }) + } + + fn fetch_worktrees_for_picker(&mut self) { + let query = WorktreeListQuery { + codex_home: self.config.codex_home.to_path_buf(), + source_cwd: Some(self.config.cwd.to_path_buf()), + include_all_repos: false, + }; + let cwd = self.config.cwd.to_path_buf(); + let app_event_tx = self.app_event_tx.clone(); + tokio::task::spawn_blocking(move || { + let result = codex_worktree::list_worktrees(query).map_err(|err| err.to_string()); + app_event_tx.send(AppEvent::WorktreesLoaded { cwd, result }); + }); + } + + async fn switch_to_worktree_info( + &mut self, + tui: &mut tui::Tui, + app_server: &mut AppServerSession, + info: WorktreeInfo, + ) { + let mut config = match self + .rebuild_config_for_cwd(info.workspace_cwd.clone()) + .await + { + Ok(config) => config, + Err(err) => { + self.show_worktree_error( + "Failed to rebuild configuration for worktree.".to_string(), + err.to_string(), + ); + return; + } + }; + self.apply_runtime_policy_overrides(&mut config); + + if let Some(thread_id) = self.chat_widget.thread_id() { + match app_server.fork_thread(config.clone(), thread_id).await { + Ok(forked) => { + self.shutdown_current_thread(app_server).await; + self.install_worktree_config(tui, config); + if let Err(err) = self + .replace_chat_widget_with_app_server_thread( + tui, app_server, forked, /*initial_user_message*/ None, + ) + .await + { + self.show_worktree_error( + "Failed to attach to worktree thread.".to_string(), + err.to_string(), + ); + } + } + Err(err) => { + self.show_worktree_error( + "Failed to fork current session into worktree.".to_string(), + err.to_string(), + ); + } + } + } else { + self.shutdown_current_thread(app_server).await; + self.install_worktree_config(tui, config.clone()); + match app_server.start_thread(&config).await { + Ok(started) => { + if let Err(err) = self + .replace_chat_widget_with_app_server_thread( + tui, app_server, started, /*initial_user_message*/ None, + ) + .await + { + self.show_worktree_error( + "Failed to attach to worktree thread.".to_string(), + err.to_string(), + ); + } + } + Err(err) => { + self.show_worktree_error( + "Failed to start session in worktree.".to_string(), + err.to_string(), + ); + } + } + } + tui.frame_requester().schedule_frame(); + } + + async fn show_worktree_switching_view(&mut self, tui: &mut tui::Tui, target: String) { + self.chat_widget + .show_selection_view(crate::worktree::switching_params( + target, + tui.frame_requester(), + self.config.animations, + )); + tui.frame_requester().schedule_frame(); + tokio::task::yield_now().await; + } + + fn replace_worktree_view(&mut self, params: crate::bottom_pane::SelectionViewParams) -> bool { + self.chat_widget + .replace_selection_view_if_active(crate::worktree::WORKTREE_SELECTION_VIEW_ID, params) + } + + fn show_worktree_error(&mut self, summary: String, error: String) { + let params = crate::worktree::error_with_summary_params(summary.clone(), error.clone()); + if !self.replace_worktree_view(params) { + self.chat_widget + .add_error_message(format!("{summary} {error}")); + } + } + + fn install_worktree_config(&mut self, tui: &mut tui::Tui, config: Config) { + self.config = config; + tui.set_notification_settings( + self.config.tui_notifications.method, + self.config.tui_notifications.condition, + ); + self.file_search + .update_search_dir(self.config.cwd.to_path_buf()); + } +} diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 4ee405f49525..34a31e3d894c 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -31,6 +31,7 @@ use codex_protocol::ThreadId; use codex_protocol::openai_models::ModelPreset; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_approval_presets::ApprovalPreset; +use codex_worktree::DirtyPolicy; use crate::app_command::AppCommand; use crate::bottom_pane::ApprovalRequest; @@ -191,6 +192,40 @@ pub(crate) enum AppEvent { /// Fork the current session into a new thread. ForkCurrentSession, + /// Open the managed worktree picker. + OpenWorktreePicker, + + /// Result of loading worktrees for the managed worktree picker. + WorktreesLoaded { + cwd: PathBuf, + result: Result, String>, + }, + + /// Create or reuse a managed worktree and switch the TUI into it. + CreateWorktreeAndSwitch { + branch: String, + base_ref: Option, + dirty_policy: Option, + }, + + /// Switch the TUI into an existing worktree. + SwitchToWorktree { + target: String, + }, + + /// Show the filesystem path for an existing worktree. + ShowWorktreePath { + target: String, + }, + + /// Remove a Codex-managed worktree. + RemoveWorktree { + target: String, + force: bool, + delete_branch: bool, + confirmed: bool, + }, + /// Request to exit the application. /// /// Use `ShutdownFirst` for user-initiated quits so core cleanup runs and the diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 0dbf0d235a1f..aac43984de95 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -5377,6 +5377,21 @@ impl ChatWidget { self.request_redraw(); } + pub(crate) fn replace_selection_view_if_active( + &mut self, + view_id: &'static str, + params: SelectionViewParams, + ) -> bool { + let replaced = self + .bottom_pane + .replace_selection_view_if_active(view_id, params); + if replaced { + self.refresh_plan_mode_nudge(); + self.request_redraw(); + } + replaced + } + pub(crate) fn no_modal_or_popup_active(&self) -> bool { self.bottom_pane.no_modal_or_popup_active() } diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index 6d1278ea2d58..f5f6f39271be 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -154,6 +154,9 @@ impl ChatWidget { SlashCommand::Fork => { self.app_event_tx.send(AppEvent::ForkCurrentSession); } + SlashCommand::Worktree => { + self.app_event_tx.send(AppEvent::OpenWorktreePicker); + } SlashCommand::Init => { let init_target = self.config.cwd.join(DEFAULT_AGENTS_MD_FILENAME); if init_target.exists() { @@ -772,6 +775,13 @@ impl ChatWidget { self.app_event_tx .send(AppEvent::ResumeSessionByIdOrName(args)); } + SlashCommand::Worktree if !trimmed.is_empty() => { + if let Err(message) = + crate::worktree::dispatch_worktree_slash_args(trimmed, &self.app_event_tx) + { + self.add_error_message(message); + } + } SlashCommand::SandboxReadRoot if !trimmed.is_empty() => { self.app_event_tx .send(AppEvent::BeginWindowsSandboxGrantReadRoot { path: args }); @@ -918,6 +928,7 @@ impl ChatWidget { | SlashCommand::Clear | SlashCommand::Resume | SlashCommand::Fork + | SlashCommand::Worktree | SlashCommand::Init | SlashCommand::Compact | SlashCommand::Review diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 26ec4ec6e245..48de5bbfc4ae 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -179,6 +179,7 @@ mod tui; mod ui_consts; pub(crate) mod update_action; pub use update_action::UpdateAction; +mod worktree; mod worktree_labels; #[cfg(not(debug_assertions))] pub use update_action::get_update_action; diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index d5e923f0e39c..9e3e19a7f33f 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -33,6 +33,7 @@ pub enum SlashCommand { New, Resume, Fork, + Worktree, Init, Compact, Plan, @@ -87,6 +88,7 @@ impl SlashCommand { SlashCommand::Resume => "resume a saved chat", SlashCommand::Clear => "clear the terminal and start a new chat", SlashCommand::Fork => "fork the current chat", + SlashCommand::Worktree => "manage worktrees", SlashCommand::Quit | SlashCommand::Exit => "exit Codex", SlashCommand::Copy => "copy last response as markdown", SlashCommand::Raw => "toggle raw scrollback mode for copy-friendly terminal selection", @@ -158,6 +160,7 @@ impl SlashCommand { | SlashCommand::Raw | SlashCommand::Side | SlashCommand::Resume + | SlashCommand::Worktree | SlashCommand::SandboxReadRoot ) } @@ -181,6 +184,7 @@ impl SlashCommand { SlashCommand::New | SlashCommand::Resume | SlashCommand::Fork + | SlashCommand::Worktree | SlashCommand::Init | SlashCommand::Compact | SlashCommand::Model diff --git a/codex-rs/tui/src/snapshots/codex_tui__worktree__tests__worktree_dirty_policy_prompt.snap b/codex-rs/tui/src/snapshots/codex_tui__worktree__tests__worktree_dirty_policy_prompt.snap new file mode 100644 index 000000000000..548fda3de9a1 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__worktree__tests__worktree_dirty_policy_prompt.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/worktree.rs +expression: "render_selection(dirty_policy_prompt_params(\"fcoury/demo\".to_string(), None),\n82)" +--- + + Source checkout has uncommitted changes + Choose what to carry into the new worktree. + +› 1. Fail Cancel creation and leave the source checkout unchanged. + 2. Ignore Create from the requested base without copying local changes. + 3. Copy tracked Copy staged and unstaged tracked changes. + 4. Copy all Copy tracked changes and untracked files. + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/snapshots/codex_tui__worktree__tests__worktree_loading.snap b/codex-rs/tui/src/snapshots/codex_tui__worktree__tests__worktree_loading.snap new file mode 100644 index 000000000000..ace4a3f023c6 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__worktree__tests__worktree_loading.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/worktree.rs +expression: "render_selection(loading_params(FrameRequester::test_dummy(), false), 92)" +--- + + Worktrees + • Loading worktrees... + This can take a moment when Codex is checking app, CLI, and Git worktrees. + +› Loading worktrees... This can take a moment when Codex is checking app, CLI, and Git + worktrees. + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/snapshots/codex_tui__worktree__tests__worktree_picker.snap b/codex-rs/tui/src/snapshots/codex_tui__worktree__tests__worktree_picker.snap new file mode 100644 index 000000000000..ef0b294c81ae --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__worktree__tests__worktree_picker.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/worktree.rs +expression: "render_selection(params, 86)" +--- + + Worktrees + Select a worktree to fork this chat into that workspace. + + Search worktrees +› fcoury/demo (current) Fork this chat into /repo/codex.fcoury-demo + codex clean · app · /repo/codex.codex + main dirty · git · /repo/codex.main + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/snapshots/codex_tui__worktree__tests__worktree_remove_confirmation.snap b/codex-rs/tui/src/snapshots/codex_tui__worktree__tests__worktree_remove_confirmation.snap new file mode 100644 index 000000000000..59892b7a45b3 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__worktree__tests__worktree_remove_confirmation.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/worktree.rs +expression: "render_selection(remove_confirmation_params(\"fcoury/demo\".to_string(), false,\nfalse), 80)" +--- + + Remove worktree fcoury/demo? + Only Codex-managed worktrees can be removed. + +› 1. Remove Remove the selected worktree. + 2. Cancel Keep the worktree. + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/snapshots/codex_tui__worktree__tests__worktree_switching.snap b/codex-rs/tui/src/snapshots/codex_tui__worktree__tests__worktree_switching.snap new file mode 100644 index 000000000000..11ad971539cf --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__worktree__tests__worktree_switching.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/worktree.rs +expression: "render_selection(switching_params(\"fcoury/demo\".to_string(),\nFrameRequester::test_dummy(), false), 92)" +--- + + Worktrees + • Switching to fcoury/demo... + Codex is rebuilding configuration and starting the chat in that workspace. + +› Preparing worktree session... Codex is rebuilding configuration and starting the + chat in that workspace. + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/worktree.rs b/codex-rs/tui/src/worktree.rs new file mode 100644 index 000000000000..a9117669537b --- /dev/null +++ b/codex-rs/tui/src/worktree.rs @@ -0,0 +1,708 @@ +use std::path::Path; +use std::time::Duration; +use std::time::Instant; + +use codex_worktree::DirtyPolicy; +use codex_worktree::WorktreeInfo; +use codex_worktree::WorktreeSource; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::widgets::Paragraph; +use ratatui::widgets::WidgetRef; + +use crate::app_event::AppEvent; +use crate::app_event_sender::AppEventSender; +use crate::bottom_pane::ColumnWidthMode; +use crate::bottom_pane::SelectionItem; +use crate::bottom_pane::SelectionRowDisplay; +use crate::bottom_pane::SelectionViewParams; +use crate::bottom_pane::popup_consts::standard_popup_hint_line; +use crate::motion::MotionMode; +use crate::motion::ReducedMotionIndicator; +use crate::motion::activity_indicator; +use crate::render::renderable::ColumnRenderable; +use crate::render::renderable::Renderable; +use crate::tui::FrameRequester; + +const WORKTREE_USAGE: &str = + "Usage: /worktree [list|new |switch |path |remove ]"; +pub(crate) const WORKTREE_SELECTION_VIEW_ID: &str = "worktree-selection"; +const LOADING_ANIMATION_INTERVAL: Duration = Duration::from_millis(100); + +struct WorktreeLoadingHeader { + started_at: Instant, + frame_requester: FrameRequester, + animations_enabled: bool, + status: String, + note: String, +} + +impl WorktreeLoadingHeader { + fn new( + frame_requester: FrameRequester, + animations_enabled: bool, + status: String, + note: String, + ) -> Self { + Self { + started_at: Instant::now(), + frame_requester, + animations_enabled, + status, + note, + } + } +} + +impl Renderable for WorktreeLoadingHeader { + fn render(&self, area: Rect, buf: &mut Buffer) { + if area.is_empty() { + return; + } + + let motion_mode = MotionMode::from_animations_enabled(self.animations_enabled); + if self.animations_enabled { + self.frame_requester + .schedule_frame_in(LOADING_ANIMATION_INTERVAL); + } + + let mut loading_spans = Vec::new(); + if let Some(indicator) = activity_indicator( + Some(self.started_at), + motion_mode, + ReducedMotionIndicator::StaticBullet, + ) { + loading_spans.push(indicator); + loading_spans.push(" ".into()); + } + loading_spans.push(self.status.clone().dim()); + + Paragraph::new(vec![ + Line::from("Worktrees".bold()), + Line::from(loading_spans), + Line::from(self.note.clone().dim()), + ]) + .render_ref(area, buf); + } + + fn desired_height(&self, _width: u16) -> u16 { + 3 + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum WorktreeSlashAction { + OpenPicker, + Create { + branch: String, + base_ref: Option, + dirty_policy: Option, + }, + Switch { + target: String, + }, + ShowPath { + target: String, + }, + Remove { + target: String, + force: bool, + delete_branch: bool, + }, +} + +impl WorktreeSlashAction { + pub(crate) fn dispatch(self, tx: &AppEventSender) { + match self { + WorktreeSlashAction::OpenPicker => tx.send(AppEvent::OpenWorktreePicker), + WorktreeSlashAction::Create { + branch, + base_ref, + dirty_policy, + } => tx.send(AppEvent::CreateWorktreeAndSwitch { + branch, + base_ref, + dirty_policy, + }), + WorktreeSlashAction::Switch { target } => { + tx.send(AppEvent::SwitchToWorktree { target }); + } + WorktreeSlashAction::ShowPath { target } => { + tx.send(AppEvent::ShowWorktreePath { target }); + } + WorktreeSlashAction::Remove { + target, + force, + delete_branch, + } => tx.send(AppEvent::RemoveWorktree { + target, + force, + delete_branch, + confirmed: force, + }), + } + } +} + +pub(crate) fn parse_worktree_slash_args(args: &str) -> Result { + let mut parts = args.split_whitespace(); + let Some(command) = parts.next() else { + return Ok(WorktreeSlashAction::OpenPicker); + }; + match command { + "list" => Ok(WorktreeSlashAction::OpenPicker), + "new" => parse_new(parts), + "switch" | "move" => { + let target = required_target(parts, command)?; + Ok(WorktreeSlashAction::Switch { target }) + } + "path" => { + let target = required_target(parts, command)?; + Ok(WorktreeSlashAction::ShowPath { target }) + } + "remove" => parse_remove(parts), + _ => Err(WORKTREE_USAGE.to_string()), + } +} + +fn parse_new<'a>(mut parts: impl Iterator) -> Result { + let Some(branch) = parts.next() else { + return Err("Usage: /worktree new [--base ] [--dirty ]".to_string()); + }; + let mut base_ref = None; + let mut dirty_policy = None; + while let Some(flag) = parts.next() { + match flag { + "--base" => { + let Some(value) = parts.next() else { + return Err("Usage: /worktree new --base ".to_string()); + }; + base_ref = Some(value.to_string()); + } + "--dirty" => { + let Some(value) = parts.next() else { + return Err("Usage: /worktree new --dirty ".to_string()); + }; + dirty_policy = Some(parse_dirty_policy(value)?); + } + _ => return Err(format!("Unknown /worktree new option '{flag}'.")), + } + } + Ok(WorktreeSlashAction::Create { + branch: branch.to_string(), + base_ref, + dirty_policy, + }) +} + +fn parse_remove<'a>( + mut parts: impl Iterator, +) -> Result { + let Some(target) = parts.next() else { + return Err( + "Usage: /worktree remove [--force] [--delete-branch]".to_string(), + ); + }; + let mut force = false; + let mut delete_branch = false; + for flag in parts { + match flag { + "--force" => force = true, + "--delete-branch" => delete_branch = true, + _ => return Err(format!("Unknown /worktree remove option '{flag}'.")), + } + } + Ok(WorktreeSlashAction::Remove { + target: target.to_string(), + force, + delete_branch, + }) +} + +fn required_target<'a>( + mut parts: impl Iterator, + command: &str, +) -> Result { + let Some(target) = parts.next() else { + return Err(format!("Usage: /worktree {command} ")); + }; + if parts.next().is_some() { + return Err(format!("Usage: /worktree {command} ")); + } + Ok(target.to_string()) +} + +fn parse_dirty_policy(value: &str) -> Result { + match value { + "fail" => Ok(DirtyPolicy::Fail), + "ignore" => Ok(DirtyPolicy::Ignore), + "copy-tracked" => Ok(DirtyPolicy::CopyTracked), + "copy-all" => Ok(DirtyPolicy::CopyAll), + _ => Err("Dirty mode must be one of: fail, ignore, copy-tracked, copy-all.".to_string()), + } +} + +pub(crate) fn dispatch_worktree_slash_args(args: &str, tx: &AppEventSender) -> Result<(), String> { + parse_worktree_slash_args(args)?.dispatch(tx); + Ok(()) +} + +pub(crate) fn loading_params( + frame_requester: FrameRequester, + animations_enabled: bool, +) -> SelectionViewParams { + let status = "Loading worktrees...".to_string(); + let note = + "This can take a moment when Codex is checking app, CLI, and Git worktrees.".to_string(); + SelectionViewParams { + view_id: Some(WORKTREE_SELECTION_VIEW_ID), + header: Box::new(WorktreeLoadingHeader::new( + frame_requester, + animations_enabled, + status.clone(), + note.clone(), + )), + footer_hint: Some(standard_popup_hint_line()), + items: vec![SelectionItem { + name: status, + description: Some(note), + is_disabled: true, + ..Default::default() + }], + ..Default::default() + } +} + +pub(crate) fn switching_params( + target: String, + frame_requester: FrameRequester, + animations_enabled: bool, +) -> SelectionViewParams { + let status = format!("Switching to {target}..."); + let note = + "Codex is rebuilding configuration and starting the chat in that workspace.".to_string(); + SelectionViewParams { + view_id: Some(WORKTREE_SELECTION_VIEW_ID), + header: Box::new(WorktreeLoadingHeader::new( + frame_requester, + animations_enabled, + status, + note.clone(), + )), + footer_hint: Some(standard_popup_hint_line()), + items: vec![SelectionItem { + name: "Preparing worktree session...".to_string(), + description: Some(note), + is_disabled: true, + ..Default::default() + }], + ..Default::default() + } +} + +pub(crate) fn empty_params() -> SelectionViewParams { + let mut header = ColumnRenderable::new(); + header.push(Line::from("Worktrees".bold())); + + SelectionViewParams { + view_id: Some(WORKTREE_SELECTION_VIEW_ID), + header: Box::new(header), + footer_hint: Some(standard_popup_hint_line()), + items: vec![SelectionItem { + name: "No worktrees found for this repository.".to_string(), + description: Some("Use /worktree new to create one.".to_string()), + is_disabled: true, + ..Default::default() + }], + ..Default::default() + } +} + +pub(crate) fn error_params(error: String) -> SelectionViewParams { + error_with_summary_params("Failed to list worktrees.".to_string(), error) +} + +pub(crate) fn error_with_summary_params(summary: String, error: String) -> SelectionViewParams { + let mut header = ColumnRenderable::new(); + header.push(Line::from("Worktrees".bold())); + + SelectionViewParams { + view_id: Some(WORKTREE_SELECTION_VIEW_ID), + header: Box::new(header), + footer_hint: Some(standard_popup_hint_line()), + items: vec![SelectionItem { + name: summary, + description: Some(error), + is_disabled: true, + ..Default::default() + }], + ..Default::default() + } +} + +pub(crate) fn picker_params(entries: Vec, current_cwd: &Path) -> SelectionViewParams { + let items = entries + .into_iter() + .map(|entry| { + let target = entry.branch.clone().unwrap_or_else(|| entry.name.clone()); + let source = source_label(entry.source); + let status = if entry.dirty.is_dirty() { + "dirty" + } else { + "clean" + }; + let description = format!("{status} · {source} · {}", entry.workspace_cwd.display()); + let search_value = Some(format!( + "{} {} {} {}", + target, + entry.name, + source, + entry.workspace_cwd.display() + )); + SelectionItem { + name: target.clone(), + description: Some(description), + selected_description: Some(format!( + "Fork this chat into {}", + entry.workspace_cwd.display() + )), + is_current: paths_match(current_cwd, &entry.workspace_cwd), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::SwitchToWorktree { + target: target.clone(), + }); + })], + dismiss_on_select: true, + search_value, + ..Default::default() + } + }) + .collect::>(); + + let mut header = ColumnRenderable::new(); + header.push(Line::from("Worktrees".bold())); + header.push(Line::from( + "Select a worktree to fork this chat into that workspace.".dim(), + )); + + SelectionViewParams { + view_id: Some(WORKTREE_SELECTION_VIEW_ID), + header: Box::new(header), + footer_hint: Some(standard_popup_hint_line()), + items, + is_searchable: true, + search_placeholder: Some("Search worktrees".to_string()), + col_width_mode: ColumnWidthMode::AutoAllRows, + row_display: SelectionRowDisplay::SingleLine, + ..Default::default() + } +} + +pub(crate) fn dirty_policy_prompt_params( + branch: String, + base_ref: Option, +) -> SelectionViewParams { + let mut header = ColumnRenderable::new(); + header.push(Line::from("Source checkout has uncommitted changes".bold())); + header.push(Line::from( + "Choose what to carry into the new worktree.".dim(), + )); + let item = |name: &str, description: &str, dirty_policy: DirtyPolicy| SelectionItem { + name: name.to_string(), + description: Some(description.to_string()), + actions: vec![Box::new({ + let branch = branch.clone(); + let base_ref = base_ref.clone(); + move |tx| { + tx.send(AppEvent::CreateWorktreeAndSwitch { + branch: branch.clone(), + base_ref: base_ref.clone(), + dirty_policy: Some(dirty_policy), + }); + } + })], + dismiss_on_select: true, + ..Default::default() + }; + SelectionViewParams { + header: Box::new(header), + footer_hint: Some(standard_popup_hint_line()), + items: vec![ + item( + "Fail", + "Cancel creation and leave the source checkout unchanged.", + DirtyPolicy::Fail, + ), + item( + "Ignore", + "Create from the requested base without copying local changes.", + DirtyPolicy::Ignore, + ), + item( + "Copy tracked", + "Copy staged and unstaged tracked changes.", + DirtyPolicy::CopyTracked, + ), + item( + "Copy all", + "Copy tracked changes and untracked files.", + DirtyPolicy::CopyAll, + ), + ], + ..Default::default() + } +} + +pub(crate) fn remove_confirmation_params( + target: String, + force: bool, + delete_branch: bool, +) -> SelectionViewParams { + let mut header = ColumnRenderable::new(); + header.push(Line::from(format!("Remove worktree {target}?").bold())); + header.push(Line::from( + "Only Codex-managed worktrees can be removed.".dim(), + )); + + SelectionViewParams { + header: Box::new(header), + footer_hint: Some(standard_popup_hint_line()), + items: vec![ + SelectionItem { + name: "Remove".to_string(), + description: Some("Remove the selected worktree.".to_string()), + actions: vec![Box::new({ + move |tx| { + tx.send(AppEvent::RemoveWorktree { + target: target.clone(), + force, + delete_branch, + confirmed: true, + }); + } + })], + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Cancel".to_string(), + description: Some("Keep the worktree.".to_string()), + dismiss_on_select: true, + ..Default::default() + }, + ], + ..Default::default() + } +} + +pub(crate) fn find_worktree<'a>( + entries: &'a [WorktreeInfo], + target: &str, +) -> Result<&'a WorktreeInfo, String> { + let matches = entries + .iter() + .filter(|entry| { + entry.branch.as_deref() == Some(target) || entry.name == target || entry.slug == target + }) + .collect::>(); + match matches.as_slice() { + [entry] => Ok(entry), + [] => Err(format!("No worktree found matching '{target}'.")), + _ => Err(format!( + "Multiple worktrees match '{target}'; use a more specific name." + )), + } +} + +pub(crate) fn source_label(source: WorktreeSource) -> &'static str { + match source { + WorktreeSource::Cli => "cli", + WorktreeSource::App => "app", + WorktreeSource::Legacy => "legacy", + WorktreeSource::Git => "git", + } +} + +fn paths_match(a: &Path, b: &Path) -> bool { + let a = a.canonicalize().unwrap_or_else(|_| a.to_path_buf()); + let b = b.canonicalize().unwrap_or_else(|_| b.to_path_buf()); + a == b +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + use crate::app_event_sender::AppEventSender; + use crate::bottom_pane::ListSelectionView; + use crate::keymap::RuntimeKeymap; + use crate::render::renderable::Renderable; + use crate::tui::FrameRequester; + use codex_worktree::DirtyState; + use codex_worktree::WorktreeLocation; + use pretty_assertions::assert_eq; + use ratatui::buffer::Buffer; + use ratatui::layout::Rect; + use tokio::sync::mpsc::unbounded_channel; + + #[test] + fn parse_new_with_flags() { + assert_eq!( + parse_worktree_slash_args("new fcoury/demo --base origin/main --dirty copy-tracked"), + Ok(WorktreeSlashAction::Create { + branch: "fcoury/demo".to_string(), + base_ref: Some("origin/main".to_string()), + dirty_policy: Some(DirtyPolicy::CopyTracked), + }) + ); + } + + #[test] + fn parse_switch_aliases_move() { + assert_eq!( + parse_worktree_slash_args("move fcoury/demo"), + Ok(WorktreeSlashAction::Switch { + target: "fcoury/demo".to_string(), + }) + ); + } + + #[test] + fn parse_remove_with_flags() { + assert_eq!( + parse_worktree_slash_args("remove fcoury/demo --force --delete-branch"), + Ok(WorktreeSlashAction::Remove { + target: "fcoury/demo".to_string(), + force: true, + delete_branch: true, + }) + ); + } + + #[test] + fn worktree_picker_snapshot() { + let params = picker_params( + vec![ + sample_info("fcoury/demo", WorktreeSource::Cli, /*dirty*/ false), + sample_info("codex", WorktreeSource::App, /*dirty*/ false), + sample_info("main", WorktreeSource::Git, /*dirty*/ true), + ], + Path::new("/repo/codex.fcoury-demo"), + ); + insta::assert_snapshot!("worktree_picker", render_selection(params, /*width*/ 86)); + } + + #[test] + fn worktree_loading_snapshot() { + insta::assert_snapshot!( + "worktree_loading", + render_selection( + loading_params( + FrameRequester::test_dummy(), + /*animations_enabled*/ false + ), + /*width*/ 92 + ) + ); + } + + #[test] + fn worktree_switching_snapshot() { + insta::assert_snapshot!( + "worktree_switching", + render_selection( + switching_params( + "fcoury/demo".to_string(), + FrameRequester::test_dummy(), + /*animations_enabled*/ false + ), + /*width*/ 92 + ) + ); + } + + #[test] + fn worktree_dirty_policy_prompt_snapshot() { + insta::assert_snapshot!( + "worktree_dirty_policy_prompt", + render_selection( + dirty_policy_prompt_params("fcoury/demo".to_string(), /*base_ref*/ None), + /*width*/ 82 + ) + ); + } + + #[test] + fn worktree_remove_confirmation_snapshot() { + insta::assert_snapshot!( + "worktree_remove_confirmation", + render_selection( + remove_confirmation_params( + "fcoury/demo".to_string(), + /*force*/ false, + /*delete_branch*/ false + ), + /*width*/ 80 + ) + ); + } + + fn sample_info(branch: &str, source: WorktreeSource, dirty: bool) -> WorktreeInfo { + let path = PathBuf::from(format!("/repo/codex.{}", branch.replace('/', "-"))); + WorktreeInfo { + id: "repo-id".to_string(), + name: branch.to_string(), + slug: branch.replace('/', "-"), + source, + location: match source { + WorktreeSource::Cli => WorktreeLocation::Sibling, + WorktreeSource::App | WorktreeSource::Legacy => WorktreeLocation::CodexHome, + WorktreeSource::Git => WorktreeLocation::External, + }, + repo_name: "codex".to_string(), + repo_root: path.clone(), + common_git_dir: PathBuf::from("/repo/codex/.git"), + worktree_git_root: path.clone(), + workspace_cwd: path, + original_relative_cwd: PathBuf::new(), + branch: Some(branch.to_string()), + head: Some("abcdef".to_string()), + owner_thread_id: None, + metadata_path: PathBuf::from("/repo/codex/.git/codex-worktree.json"), + dirty: DirtyState { + has_staged_changes: false, + has_unstaged_changes: dirty, + has_untracked_files: false, + }, + } + } + + fn render_selection(params: SelectionViewParams, width: u16) -> String { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let view = ListSelectionView::new(params, tx, RuntimeKeymap::defaults().list); + let height = view.desired_height(width); + let area = Rect::new(/*x*/ 0, /*y*/ 0, width, height); + let mut buf = Buffer::empty(area); + view.render(area, &mut buf); + + let lines: Vec = (0..area.height) + .map(|row| { + let mut line = String::new(); + for col in 0..area.width { + let symbol = buf[(area.x + col, area.y + row)].symbol(); + if symbol.is_empty() { + line.push(' '); + } else { + line.push_str(symbol); + } + } + line.trim_end().to_string() + }) + .collect(); + lines.join("\n") + } +} diff --git a/codex-rs/worktree/src/lib.rs b/codex-rs/worktree/src/lib.rs index a5adb60bc76d..3ca1a70f41a1 100644 --- a/codex-rs/worktree/src/lib.rs +++ b/codex-rs/worktree/src/lib.rs @@ -11,6 +11,7 @@ use serde::Serialize; pub use dirty::DirtyPolicy; pub use dirty::DirtyState; +pub use dirty::dirty_state; pub use manager::ensure_worktree; pub use manager::list_worktrees; pub use manager::remove_worktree; From 4f3955ff91ad05773dda3cb1baadc6179c9fa1de Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Wed, 6 May 2026 23:58:02 -0300 Subject: [PATCH 03/12] fix(tui): keep worktree switching responsive --- codex-rs/tui/src/app/event_dispatch.rs | 16 +- codex-rs/tui/src/app/worktree.rs | 425 ++++++++++++++++-- codex-rs/tui/src/app_event.rs | 15 + codex-rs/tui/src/app_server_session.rs | 97 ++++ .../tui/src/chatwidget/status_surfaces.rs | 20 +- codex-rs/tui/src/motion.rs | 29 ++ codex-rs/tui/src/worktree.rs | 21 +- 7 files changed, 547 insertions(+), 76 deletions(-) diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 892b3734e1a9..b542db85d39e 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -205,7 +205,21 @@ impl App { tui.frame_requester().schedule_frame(); } AppEvent::SwitchToWorktree { target } => { - self.switch_to_worktree_target(tui, app_server, target) + self.begin_switch_to_worktree_target(tui, target); + tui.frame_requester().schedule_frame(); + } + AppEvent::SwitchToWorktreeAfterLoading { target } => { + self.switch_to_worktree_target_after_loading(tui, app_server, target) + .await; + tui.frame_requester().schedule_frame(); + } + AppEvent::WorktreeSessionReady { + info, + config, + forked, + result, + } => { + self.on_worktree_session_ready(tui, app_server, info, config, forked, result) .await; tui.frame_requester().schedule_frame(); } diff --git a/codex-rs/tui/src/app/worktree.rs b/codex-rs/tui/src/app/worktree.rs index 11d689b0d6dd..de33a8f022e4 100644 --- a/codex-rs/tui/src/app/worktree.rs +++ b/codex-rs/tui/src/app/worktree.rs @@ -1,15 +1,41 @@ //! App-layer handlers for the worktree TUI flow. +use codex_protocol::ThreadId; use codex_worktree::DirtyPolicy; use codex_worktree::WorktreeInfo; use codex_worktree::WorktreeListQuery; use codex_worktree::WorktreeRemoveRequest; use codex_worktree::WorktreeRequest; use codex_worktree::WorktreeSource; +use std::path::Path; use std::path::PathBuf; +use std::time::Duration; use super::*; +const WORKTREE_SWITCH_RENDER_DELAY: Duration = Duration::from_millis(20); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum WorktreeSwitchMode { + StartFresh, + Fork(ThreadId), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum WorktreeSessionTransition { + Forked, + Started, +} + +impl WorktreeSessionTransition { + fn message_prefix(self) -> &'static str { + match self { + WorktreeSessionTransition::Forked => "Forked into", + WorktreeSessionTransition::Started => "Started session in", + } + } +} + impl App { pub(super) fn open_worktree_picker(&mut self, tui: &mut tui::Tui) { if self.remote_app_server_url.is_some() { @@ -97,7 +123,7 @@ impl App { .branch .clone() .unwrap_or_else(|| resolution.info.name.clone()); - self.show_worktree_switching_view(tui, target).await; + self.show_worktree_switching_view(tui, target); self.switch_to_worktree_info(tui, app_server, resolution.info) .await; for warning in warnings { @@ -105,7 +131,18 @@ impl App { } } - pub(super) async fn switch_to_worktree_target( + pub(super) fn begin_switch_to_worktree_target(&mut self, tui: &mut tui::Tui, target: String) { + if self.remote_app_server_url.is_some() { + self.chat_widget.add_error_message( + "/worktree is not supported for remote sessions yet.".to_string(), + ); + return; + } + self.show_worktree_switching_view(tui, target.clone()); + self.defer_switch_to_worktree_target(target); + } + + pub(super) async fn switch_to_worktree_target_after_loading( &mut self, tui: &mut tui::Tui, app_server: &mut AppServerSession, @@ -117,7 +154,6 @@ impl App { ); return; } - self.show_worktree_switching_view(tui, target.clone()).await; let entries = match self.list_current_repo_worktrees() { Ok(entries) => entries, Err(err) => { @@ -264,59 +300,142 @@ impl App { }; self.apply_runtime_policy_overrides(&mut config); - if let Some(thread_id) = self.chat_widget.thread_id() { - match app_server.fork_thread(config.clone(), thread_id).await { - Ok(forked) => { - self.shutdown_current_thread(app_server).await; - self.install_worktree_config(tui, config); - if let Err(err) = self - .replace_chat_widget_with_app_server_thread( - tui, app_server, forked, /*initial_user_message*/ None, - ) - .await - { - self.show_worktree_error( - "Failed to attach to worktree thread.".to_string(), - err.to_string(), - ); - } - } - Err(err) => { + let mode = self.worktree_switch_mode().await; + self.spawn_worktree_session_request(app_server, info, config, mode); + tui.frame_requester().schedule_frame(); + } + + pub(super) async fn on_worktree_session_ready( + &mut self, + tui: &mut tui::Tui, + app_server: &mut AppServerSession, + info: WorktreeInfo, + config: Config, + forked: bool, + result: Result, + ) { + match result { + Ok(started) => { + self.shutdown_current_thread(app_server).await; + self.install_worktree_config(tui, config); + if let Err(err) = self + .replace_chat_widget_with_app_server_thread( + tui, app_server, started, /*initial_user_message*/ None, + ) + .await + { self.show_worktree_error( - "Failed to fork current session into worktree.".to_string(), + "Failed to attach to worktree thread.".to_string(), err.to_string(), ); + } else { + let transition = if forked { + WorktreeSessionTransition::Forked + } else { + WorktreeSessionTransition::Started + }; + self.add_worktree_session_message(&info, transition); } } - } else { - self.shutdown_current_thread(app_server).await; - self.install_worktree_config(tui, config.clone()); - match app_server.start_thread(&config).await { - Ok(started) => { - if let Err(err) = self - .replace_chat_widget_with_app_server_thread( - tui, app_server, started, /*initial_user_message*/ None, - ) - .await - { - self.show_worktree_error( - "Failed to attach to worktree thread.".to_string(), - err.to_string(), - ); - } + Err(err) => { + let summary = if forked { + "Failed to fork current session into worktree." + } else { + "Failed to start session in worktree." + }; + self.show_worktree_error(summary.to_string(), err); + } + } + tui.frame_requester().schedule_frame(); + } + + fn spawn_worktree_session_request( + &self, + app_server: &AppServerSession, + info: WorktreeInfo, + config: Config, + mode: WorktreeSwitchMode, + ) { + let request_handle = app_server.request_handle(); + let remote_cwd_override = app_server.remote_cwd_override().map(Path::to_path_buf); + let app_event_tx = self.app_event_tx.clone(); + tokio::spawn(async move { + let forked = matches!(mode, WorktreeSwitchMode::Fork(_)); + let result = match mode { + WorktreeSwitchMode::Fork(thread_id) => { + crate::app_server_session::fork_thread_with_request_handle( + request_handle, + config.clone(), + thread_id, + remote_cwd_override, + ) + .await } - Err(err) => { - self.show_worktree_error( - "Failed to start session in worktree.".to_string(), - err.to_string(), - ); + WorktreeSwitchMode::StartFresh => { + crate::app_server_session::start_thread_with_request_handle( + request_handle, + config.clone(), + remote_cwd_override, + ) + .await } } + .map_err(|err| err.to_string()); + app_event_tx.send(AppEvent::WorktreeSessionReady { + info, + config, + forked, + result, + }); + }); + } + + fn add_worktree_session_message( + &mut self, + info: &WorktreeInfo, + transition: WorktreeSessionTransition, + ) { + let (message, hint) = worktree_session_message(info, transition); + self.chat_widget.add_info_message(message, Some(hint)); + } + + async fn worktree_switch_mode(&self) -> WorktreeSwitchMode { + let Some(thread_id) = self.current_displayed_thread_id() else { + return WorktreeSwitchMode::StartFresh; + }; + + if self + .session_for_thread(thread_id) + .await + .as_ref() + .is_some_and(Self::session_has_materialized_rollout) + { + WorktreeSwitchMode::Fork(thread_id) + } else { + WorktreeSwitchMode::StartFresh } - tui.frame_requester().schedule_frame(); } - async fn show_worktree_switching_view(&mut self, tui: &mut tui::Tui, target: String) { + async fn session_for_thread(&self, thread_id: ThreadId) -> Option { + if self.primary_thread_id == Some(thread_id) + && let Some(session) = self.primary_session_configured.clone() + { + return Some(session); + } + + let channel = self.thread_event_channels.get(&thread_id)?; + let store = channel.store.lock().await; + store.session.clone() + } + + fn session_has_materialized_rollout(session: &ThreadSessionState) -> bool { + session + .rollout_path + .as_ref() + .is_some_and(|rollout_path| rollout_path.exists()) + } + + fn show_worktree_switching_view(&mut self, tui: &mut tui::Tui, target: String) { self.chat_widget .show_selection_view(crate::worktree::switching_params( target, @@ -324,7 +443,14 @@ impl App { self.config.animations, )); tui.frame_requester().schedule_frame(); - tokio::task::yield_now().await; + } + + fn defer_switch_to_worktree_target(&self, target: String) { + let app_event_tx = self.app_event_tx.clone(); + tokio::spawn(async move { + tokio::time::sleep(WORKTREE_SWITCH_RENDER_DELAY).await; + app_event_tx.send(AppEvent::SwitchToWorktreeAfterLoading { target }); + }); } fn replace_worktree_view(&mut self, params: crate::bottom_pane::SelectionViewParams) -> bool { @@ -350,3 +476,210 @@ impl App { .update_search_dir(self.config.cwd.to_path_buf()); } } + +fn worktree_session_message( + info: &WorktreeInfo, + transition: WorktreeSessionTransition, +) -> (String, String) { + let worktree_name = info.branch.as_deref().unwrap_or(info.name.as_str()); + let state = if info.dirty.is_dirty() { + "dirty" + } else { + "clean" + }; + let source = crate::worktree::source_label(info.source); + ( + format!( + "{} {source} worktree {worktree_name} · {state} · {}", + transition.message_prefix(), + info.repo_name + ), + info.workspace_cwd.display().to_string(), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use codex_app_server_protocol::AskForApproval; + use codex_protocol::config_types::ApprovalsReviewer; + use codex_protocol::models::PermissionProfile; + use codex_utils_absolute_path::AbsolutePathBuf; + use codex_worktree::DirtyState; + use codex_worktree::WorktreeLocation; + use tempfile::TempDir; + + #[tokio::test] + async fn worktree_switch_mode_starts_fresh_without_current_thread() { + let app = crate::app::test_support::make_test_app().await; + + assert_eq!( + app.worktree_switch_mode().await, + WorktreeSwitchMode::StartFresh + ); + } + + #[tokio::test] + async fn worktree_switch_mode_starts_fresh_for_unmaterialized_primary_rollout() { + let temp_dir = TempDir::new().expect("temp dir"); + let thread_id = ThreadId::new(); + let missing_rollout_path = temp_dir.path().join("missing-rollout.jsonl"); + let session = test_thread_session( + thread_id, + temp_dir.path().join("repo"), + missing_rollout_path, + ); + let mut app = crate::app::test_support::make_test_app().await; + app.primary_thread_id = Some(thread_id); + app.active_thread_id = Some(thread_id); + app.primary_session_configured = Some(session); + + assert_eq!( + app.worktree_switch_mode().await, + WorktreeSwitchMode::StartFresh + ); + } + + #[tokio::test] + async fn worktree_switch_mode_forks_materialized_primary_rollout() { + let temp_dir = TempDir::new().expect("temp dir"); + let thread_id = ThreadId::new(); + let rollout_path = temp_dir.path().join("rollout.jsonl"); + std::fs::write(&rollout_path, "{}\\n").expect("write rollout"); + let session = test_thread_session(thread_id, temp_dir.path().join("repo"), rollout_path); + let mut app = crate::app::test_support::make_test_app().await; + app.primary_thread_id = Some(thread_id); + app.active_thread_id = Some(thread_id); + app.primary_session_configured = Some(session); + + assert_eq!( + app.worktree_switch_mode().await, + WorktreeSwitchMode::Fork(thread_id) + ); + } + + #[tokio::test] + async fn worktree_switch_mode_uses_active_non_primary_thread_session() { + let temp_dir = TempDir::new().expect("temp dir"); + let primary_thread_id = ThreadId::new(); + let active_thread_id = ThreadId::new(); + let active_rollout_path = temp_dir.path().join("active-rollout.jsonl"); + std::fs::write(&active_rollout_path, "{}\\n").expect("write rollout"); + let active_session = test_thread_session( + active_thread_id, + temp_dir.path().join("active"), + active_rollout_path, + ); + let mut app = crate::app::test_support::make_test_app().await; + app.primary_thread_id = Some(primary_thread_id); + app.active_thread_id = Some(active_thread_id); + app.primary_session_configured = Some(test_thread_session( + primary_thread_id, + temp_dir.path().join("primary"), + temp_dir.path().join("missing-primary-rollout.jsonl"), + )); + app.thread_event_channels.insert( + active_thread_id, + ThreadEventChannel::new_with_session( + THREAD_EVENT_CHANNEL_CAPACITY, + active_session, + Vec::new(), + ), + ); + + assert_eq!( + app.worktree_switch_mode().await, + WorktreeSwitchMode::Fork(active_thread_id) + ); + } + + #[test] + fn worktree_session_message_describes_forked_workspace() { + let info = test_worktree_info( + WorktreeSource::Cli, + Some("fcoury/demo".to_string()), + /*dirty*/ false, + ); + + assert_eq!( + worktree_session_message(&info, WorktreeSessionTransition::Forked), + ( + "Forked into cli worktree fcoury/demo · clean · codex".to_string(), + "/repo/codex.fcoury-demo".to_string() + ) + ); + } + + #[test] + fn worktree_session_message_describes_started_dirty_workspace() { + let info = test_worktree_info( + WorktreeSource::App, + /*branch*/ None, + /*dirty*/ true, + ); + + assert_eq!( + worktree_session_message(&info, WorktreeSessionTransition::Started), + ( + "Started session in app worktree app-worktree · dirty · codex".to_string(), + "/repo/codex.fcoury-demo".to_string() + ) + ); + } + + fn test_thread_session( + thread_id: ThreadId, + cwd: PathBuf, + rollout_path: PathBuf, + ) -> ThreadSessionState { + ThreadSessionState { + thread_id, + forked_from_id: None, + fork_parent_title: None, + thread_name: None, + model: "gpt-test".to_string(), + model_provider_id: "test-provider".to_string(), + service_tier: None, + approval_policy: AskForApproval::Never, + approvals_reviewer: ApprovalsReviewer::User, + permission_profile: PermissionProfile::read_only(), + active_permission_profile: None, + cwd: AbsolutePathBuf::try_from(cwd).expect("absolute cwd"), + instruction_source_paths: Vec::new(), + reasoning_effort: None, + message_history: None, + network_proxy: None, + rollout_path: Some(rollout_path), + } + } + + fn test_worktree_info( + source: WorktreeSource, + branch: Option, + dirty: bool, + ) -> WorktreeInfo { + let path = PathBuf::from("/repo/codex.fcoury-demo"); + WorktreeInfo { + id: "repo-id".to_string(), + name: "app-worktree".to_string(), + slug: "fcoury-demo".to_string(), + source, + location: WorktreeLocation::Sibling, + repo_name: "codex".to_string(), + repo_root: path.clone(), + common_git_dir: PathBuf::from("/repo/codex/.git"), + worktree_git_root: path.clone(), + workspace_cwd: path, + original_relative_cwd: PathBuf::new(), + branch, + head: Some("abcdef".to_string()), + owner_thread_id: None, + metadata_path: PathBuf::from("/repo/codex/.git/codex-worktree.json"), + dirty: DirtyState { + has_staged_changes: false, + has_unstaged_changes: dirty, + has_untracked_files: false, + }, + } + } +} diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 34a31e3d894c..03252ad4da54 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -34,10 +34,12 @@ use codex_utils_approval_presets::ApprovalPreset; use codex_worktree::DirtyPolicy; use crate::app_command::AppCommand; +use crate::app_server_session::AppServerStartedThread; use crate::bottom_pane::ApprovalRequest; use crate::bottom_pane::StatusLineItem; use crate::bottom_pane::TerminalTitleItem; use crate::chatwidget::UserMessage; +use crate::legacy_core::config::Config; use codex_app_server_protocol::AskForApproval; use codex_config::types::ApprovalsReviewer; use codex_features::Feature; @@ -213,6 +215,19 @@ pub(crate) enum AppEvent { target: String, }, + /// Continue switching into an existing worktree after the loading view has rendered. + SwitchToWorktreeAfterLoading { + target: String, + }, + + /// Result of starting or forking a session in a worktree. + WorktreeSessionReady { + info: codex_worktree::WorktreeInfo, + config: Config, + forked: bool, + result: Result, + }, + /// Show the filesystem path for an existing worktree. ShowWorktreePath { target: String, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 92d59d302722..543bd4988437 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -119,6 +119,7 @@ use color_eyre::eyre::WrapErr; use std::collections::HashMap; use std::path::Path; use std::path::PathBuf; +use uuid::Uuid; fn bootstrap_request_error(context: &'static str, err: TypedRequestError) -> color_eyre::Report { color_eyre::eyre::eyre!("{context}: {err}") @@ -166,6 +167,7 @@ impl ThreadParamsMode { } } +#[derive(Debug)] pub(crate) struct AppServerStartedThread { pub(crate) session: ThreadSessionState, pub(crate) turns: Vec, @@ -969,6 +971,101 @@ impl AppServerSession { } } +pub(crate) async fn start_thread_with_request_handle( + request_handle: AppServerRequestHandle, + config: Config, + remote_cwd_override: Option, +) -> Result { + let thread_params_mode = thread_params_mode_from_request_handle(&request_handle); + let response: ThreadStartResponse = request_handle + .request_typed(ClientRequest::ThreadStart { + request_id: worktree_request_id("worktree-thread-start"), + params: thread_start_params_from_config( + &config, + thread_params_mode, + remote_cwd_override.as_deref(), + /*session_start_source*/ None, + ), + }) + .await + .map_err(|err| bootstrap_request_error("thread/start failed during TUI bootstrap", err))?; + started_thread_from_start_response(response, &config, thread_params_mode).await +} + +pub(crate) async fn fork_thread_with_request_handle( + request_handle: AppServerRequestHandle, + config: Config, + thread_id: ThreadId, + remote_cwd_override: Option, +) -> Result { + let thread_params_mode = thread_params_mode_from_request_handle(&request_handle); + let response: ThreadForkResponse = request_handle + .request_typed(ClientRequest::ThreadFork { + request_id: worktree_request_id("worktree-thread-fork"), + params: thread_fork_params_from_config( + config.clone(), + thread_id, + thread_params_mode, + remote_cwd_override.as_deref(), + ), + }) + .await + .map_err(|err| bootstrap_request_error("thread/fork failed during TUI bootstrap", err))?; + let fork_parent_title = fork_parent_title_from_request_handle( + &request_handle, + response.thread.forked_from_id.as_deref(), + ) + .await; + let mut started = + started_thread_from_fork_response(response, &config, thread_params_mode).await?; + started.session.fork_parent_title = fork_parent_title; + Ok(started) +} + +fn worktree_request_id(prefix: &str) -> RequestId { + RequestId::String(format!("{prefix}-{}", Uuid::new_v4())) +} + +fn thread_params_mode_from_request_handle( + request_handle: &AppServerRequestHandle, +) -> ThreadParamsMode { + match request_handle { + AppServerRequestHandle::InProcess(_) => ThreadParamsMode::Embedded, + AppServerRequestHandle::Remote(_) => ThreadParamsMode::Remote, + } +} + +async fn fork_parent_title_from_request_handle( + request_handle: &AppServerRequestHandle, + forked_from_id: Option<&str>, +) -> Option { + let forked_from_id = forked_from_id?; + let forked_from_id = match ThreadId::from_string(forked_from_id) { + Ok(thread_id) => thread_id, + Err(err) => { + tracing::warn!("Failed to parse fork parent thread id from app server: {err}"); + return None; + } + }; + + match request_handle + .request_typed::(ClientRequest::ThreadRead { + request_id: worktree_request_id("worktree-thread-read"), + params: ThreadReadParams { + thread_id: forked_from_id.to_string(), + include_turns: false, + }, + }) + .await + { + Ok(thread) => thread.thread.name, + Err(err) => { + tracing::warn!("Failed to read fork parent metadata from app server: {err}"); + None + } + } +} + fn thread_realtime_start_params( thread_id: ThreadId, transport: Option, diff --git a/codex-rs/tui/src/chatwidget/status_surfaces.rs b/codex-rs/tui/src/chatwidget/status_surfaces.rs index 3095556fd04c..2bff82160284 100644 --- a/codex-rs/tui/src/chatwidget/status_surfaces.rs +++ b/codex-rs/tui/src/chatwidget/status_surfaces.rs @@ -6,19 +6,14 @@ use super::*; use crate::bottom_pane::status_line_from_segments; use crate::branch_summary; +use crate::motion::ACTIVITY_SPINNER_INTERVAL; +use crate::motion::activity_spinner_frame_at; use crate::status::format_tokens_compact; /// Items shown in the terminal title when the user has not configured a /// custom selection. Intentionally minimal: activity indicator + project name. pub(super) const DEFAULT_TERMINAL_TITLE_ITEMS: [&str; 2] = ["activity", "project-name"]; -/// Braille-pattern dot-spinner frames for the terminal title animation. -pub(super) const TERMINAL_TITLE_SPINNER_FRAMES: [&str; 10] = - ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; - -/// Time between spinner frame advances in the terminal title. -pub(super) const TERMINAL_TITLE_SPINNER_INTERVAL: Duration = Duration::from_millis(100); - /// Time between action-required blink phases in the terminal title. const TERMINAL_TITLE_ACTION_REQUIRED_INTERVAL: Duration = Duration::from_secs(1); @@ -362,7 +357,7 @@ impl ChatWidget { } self.should_animate_terminal_title_spinner_with_selections(selections) - .then_some(TERMINAL_TITLE_SPINNER_INTERVAL) + .then_some(ACTIVITY_SPINNER_INTERVAL) } pub(super) fn request_status_line_branch_refresh(&mut self) { @@ -816,14 +811,7 @@ impl ChatWidget { return None; } - Some(self.terminal_title_spinner_frame_at(now).to_string()) - } - - fn terminal_title_spinner_frame_at(&self, now: Instant) -> &'static str { - let elapsed = now.saturating_duration_since(self.terminal_title_animation_origin); - let frame_index = - (elapsed.as_millis() / TERMINAL_TITLE_SPINNER_INTERVAL.as_millis()) as usize; - TERMINAL_TITLE_SPINNER_FRAMES[frame_index % TERMINAL_TITLE_SPINNER_FRAMES.len()] + Some(activity_spinner_frame_at(self.terminal_title_animation_origin, now).to_string()) } fn terminal_title_uses_activity(&self) -> bool { diff --git a/codex-rs/tui/src/motion.rs b/codex-rs/tui/src/motion.rs index bb137ca653f4..4e6fa0e999af 100644 --- a/codex-rs/tui/src/motion.rs +++ b/codex-rs/tui/src/motion.rs @@ -3,6 +3,7 @@ //! Callers choose an explicit reduced-motion fallback here instead of reaching //! directly for time-varying spinner or shimmer helpers. +use std::time::Duration; use std::time::Instant; use ratatui::style::Stylize; @@ -10,6 +11,10 @@ use ratatui::text::Span; use crate::shimmer::shimmer_spans; +const ACTIVITY_SPINNER_FRAMES: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + +pub(crate) const ACTIVITY_SPINNER_INTERVAL: Duration = Duration::from_millis(100); + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum MotionMode { Animated, @@ -59,6 +64,12 @@ pub(crate) fn shimmer_text(text: &str, motion_mode: MotionMode) -> Vec &'static str { + let elapsed = now.saturating_duration_since(origin); + let frame_index = (elapsed.as_millis() / ACTIVITY_SPINNER_INTERVAL.as_millis()) as usize; + ACTIVITY_SPINNER_FRAMES[frame_index % ACTIVITY_SPINNER_FRAMES.len()] +} + fn animated_activity_indicator(start_time: Option) -> Span<'static> { let elapsed = start_time.map(|st| st.elapsed()).unwrap_or_default(); if supports_color::on_cached(supports_color::Stream::Stdout) @@ -117,6 +128,24 @@ mod tests { ); } + #[test] + fn activity_spinner_frame_advances_and_wraps() { + let origin = Instant::now(); + + assert_eq!(activity_spinner_frame_at(origin, origin), "⠋"); + assert_eq!( + activity_spinner_frame_at(origin, origin + ACTIVITY_SPINNER_INTERVAL), + "⠙" + ); + assert_eq!( + activity_spinner_frame_at( + origin, + origin + ACTIVITY_SPINNER_INTERVAL * ACTIVITY_SPINNER_FRAMES.len() as u32, + ), + "⠋" + ); + } + #[test] fn animation_primitives_are_only_used_by_motion_module() { let direct_spinner = regex_lite::Regex::new(r"(^|[^A-Za-z0-9_])spinner\s*\(").unwrap(); diff --git a/codex-rs/tui/src/worktree.rs b/codex-rs/tui/src/worktree.rs index a9117669537b..4a566dbbfd0d 100644 --- a/codex-rs/tui/src/worktree.rs +++ b/codex-rs/tui/src/worktree.rs @@ -1,5 +1,4 @@ use std::path::Path; -use std::time::Duration; use std::time::Instant; use codex_worktree::DirtyPolicy; @@ -19,9 +18,8 @@ use crate::bottom_pane::SelectionItem; use crate::bottom_pane::SelectionRowDisplay; use crate::bottom_pane::SelectionViewParams; use crate::bottom_pane::popup_consts::standard_popup_hint_line; -use crate::motion::MotionMode; -use crate::motion::ReducedMotionIndicator; -use crate::motion::activity_indicator; +use crate::motion::ACTIVITY_SPINNER_INTERVAL; +use crate::motion::activity_spinner_frame_at; use crate::render::renderable::ColumnRenderable; use crate::render::renderable::Renderable; use crate::tui::FrameRequester; @@ -29,7 +27,6 @@ use crate::tui::FrameRequester; const WORKTREE_USAGE: &str = "Usage: /worktree [list|new |switch |path |remove ]"; pub(crate) const WORKTREE_SELECTION_VIEW_ID: &str = "worktree-selection"; -const LOADING_ANIMATION_INTERVAL: Duration = Duration::from_millis(100); struct WorktreeLoadingHeader { started_at: Instant, @@ -62,19 +59,17 @@ impl Renderable for WorktreeLoadingHeader { return; } - let motion_mode = MotionMode::from_animations_enabled(self.animations_enabled); if self.animations_enabled { self.frame_requester - .schedule_frame_in(LOADING_ANIMATION_INTERVAL); + .schedule_frame_in(ACTIVITY_SPINNER_INTERVAL); } let mut loading_spans = Vec::new(); - if let Some(indicator) = activity_indicator( - Some(self.started_at), - motion_mode, - ReducedMotionIndicator::StaticBullet, - ) { - loading_spans.push(indicator); + if self.animations_enabled { + loading_spans.push(activity_spinner_frame_at(self.started_at, Instant::now()).into()); + loading_spans.push(" ".into()); + } else { + loading_spans.push("•".dim()); loading_spans.push(" ".into()); } loading_spans.push(self.status.clone().dim()); From 1b31e124449a683090a173ea33335c277ae73ae9 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Thu, 7 May 2026 20:41:34 -0300 Subject: [PATCH 04/12] feat(tui): create worktrees from slash command --- codex-rs/tui/src/app/event_dispatch.rs | 18 +- codex-rs/tui/src/app/worktree.rs | 115 ++++++++++--- codex-rs/tui/src/app_event.rs | 10 ++ codex-rs/tui/src/chatwidget.rs | 9 + ...i__worktree__tests__worktree_creating.snap | 13 ++ ..._tui__worktree__tests__worktree_empty.snap | 10 ++ ...tui__worktree__tests__worktree_picker.snap | 5 +- codex-rs/tui/src/worktree.rs | 161 +++++++++++++----- 8 files changed, 272 insertions(+), 69 deletions(-) create mode 100644 codex-rs/tui/src/snapshots/codex_tui__worktree__tests__worktree_creating.snap create mode 100644 codex-rs/tui/src/snapshots/codex_tui__worktree__tests__worktree_empty.snap diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index b542db85d39e..27035083ba22 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -191,6 +191,10 @@ impl App { self.open_worktree_picker(tui); tui.frame_requester().schedule_frame(); } + AppEvent::OpenWorktreeCreatePrompt => { + self.open_worktree_create_prompt(); + tui.frame_requester().schedule_frame(); + } AppEvent::WorktreesLoaded { cwd, result } => { self.on_worktrees_loaded(cwd, result); tui.frame_requester().schedule_frame(); @@ -200,8 +204,11 @@ impl App { base_ref, dirty_policy, } => { - self.create_worktree_and_switch(tui, app_server, branch, base_ref, dirty_policy) - .await; + self.create_worktree_and_switch(tui, branch, base_ref, dirty_policy); + tui.frame_requester().schedule_frame(); + } + AppEvent::WorktreeCreated { cwd, result } => { + self.on_worktree_created(tui, app_server, cwd, result).await; tui.frame_requester().schedule_frame(); } AppEvent::SwitchToWorktree { target } => { @@ -217,10 +224,13 @@ impl App { info, config, forked, + warnings, result, } => { - self.on_worktree_session_ready(tui, app_server, info, config, forked, result) - .await; + self.on_worktree_session_ready( + tui, app_server, info, config, forked, warnings, result, + ) + .await; tui.frame_requester().schedule_frame(); } AppEvent::ShowWorktreePath { target } => { diff --git a/codex-rs/tui/src/app/worktree.rs b/codex-rs/tui/src/app/worktree.rs index de33a8f022e4..da1538151555 100644 --- a/codex-rs/tui/src/app/worktree.rs +++ b/codex-rs/tui/src/app/worktree.rs @@ -6,12 +6,14 @@ use codex_worktree::WorktreeInfo; use codex_worktree::WorktreeListQuery; use codex_worktree::WorktreeRemoveRequest; use codex_worktree::WorktreeRequest; +use codex_worktree::WorktreeResolution; use codex_worktree::WorktreeSource; use std::path::Path; use std::path::PathBuf; use std::time::Duration; use super::*; +use crate::bottom_pane::custom_prompt_view::CustomPromptView; const WORKTREE_SWITCH_RENDER_DELAY: Duration = Duration::from_millis(20); @@ -52,6 +54,32 @@ impl App { self.fetch_worktrees_for_picker(); } + pub(super) fn open_worktree_create_prompt(&mut self) { + if self.remote_app_server_url.is_some() { + self.chat_widget.add_error_message( + "/worktree is not supported for remote sessions yet.".to_string(), + ); + return; + } + + let tx = self.app_event_tx.clone(); + let view = CustomPromptView::new( + "New worktree".to_string(), + "Type a branch name and press Enter".to_string(), + /*initial_text*/ String::new(), + /*context_label*/ + Some("Creates a sibling worktree and starts this chat there.".to_string()), + Box::new(move |branch: String| { + tx.send(AppEvent::CreateWorktreeAndSwitch { + branch: branch.trim().to_string(), + base_ref: None, + dirty_policy: None, + }); + }), + ); + self.chat_widget.show_bottom_pane_view(Box::new(view)); + } + pub(super) fn on_worktrees_loaded( &mut self, cwd: PathBuf, @@ -68,10 +96,9 @@ impl App { self.replace_worktree_view(params); } - pub(super) async fn create_worktree_and_switch( + pub(super) fn create_worktree_and_switch( &mut self, tui: &mut tui::Tui, - app_server: &mut AppServerSession, branch: String, base_ref: Option, dirty_policy: Option, @@ -99,36 +126,50 @@ impl App { }, }; - let resolution = match codex_worktree::ensure_worktree(WorktreeRequest { + self.show_worktree_creating_view(tui, branch.clone()); + self.spawn_worktree_create_request(WorktreeRequest { codex_home: self.config.codex_home.to_path_buf(), source_cwd: self.config.cwd.to_path_buf(), branch, base_ref, dirty_policy, - }) { + }); + } + + pub(super) async fn on_worktree_created( + &mut self, + tui: &mut tui::Tui, + app_server: &mut AppServerSession, + cwd: PathBuf, + result: Result, + ) { + if cwd.as_path() != self.config.cwd.as_path() { + return; + } + let resolution = match result { Ok(resolution) => resolution, Err(err) => { - self.chat_widget - .add_error_message(format!("Failed to create worktree: {err}")); + self.show_worktree_error("Failed to create worktree.".to_string(), err); return; } }; - let warnings = resolution - .warnings - .iter() - .map(|warning| warning.message.clone()) - .collect::>(); let target = resolution .info .branch .clone() .unwrap_or_else(|| resolution.info.name.clone()); self.show_worktree_switching_view(tui, target); - self.switch_to_worktree_info(tui, app_server, resolution.info) - .await; - for warning in warnings { - self.chat_widget.add_info_message(warning, /*hint*/ None); - } + self.switch_to_worktree_info( + tui, + app_server, + resolution.info, + resolution + .warnings + .into_iter() + .map(|warning| warning.message) + .collect(), + ) + .await; } pub(super) fn begin_switch_to_worktree_target(&mut self, tui: &mut tui::Tui, target: String) { @@ -168,7 +209,8 @@ impl App { return; } }; - self.switch_to_worktree_info(tui, app_server, info).await; + self.switch_to_worktree_info(tui, app_server, info, Vec::new()) + .await; } pub(super) fn show_worktree_path(&mut self, target: String) { @@ -279,11 +321,21 @@ impl App { }); } + fn spawn_worktree_create_request(&self, request: WorktreeRequest) { + let cwd = request.source_cwd.clone(); + let app_event_tx = self.app_event_tx.clone(); + tokio::task::spawn_blocking(move || { + let result = codex_worktree::ensure_worktree(request).map_err(|err| err.to_string()); + app_event_tx.send(AppEvent::WorktreeCreated { cwd, result }); + }); + } + async fn switch_to_worktree_info( &mut self, tui: &mut tui::Tui, app_server: &mut AppServerSession, info: WorktreeInfo, + warnings: Vec, ) { let mut config = match self .rebuild_config_for_cwd(info.workspace_cwd.clone()) @@ -301,7 +353,7 @@ impl App { self.apply_runtime_policy_overrides(&mut config); let mode = self.worktree_switch_mode().await; - self.spawn_worktree_session_request(app_server, info, config, mode); + self.spawn_worktree_session_request(app_server, info, config, mode, warnings); tui.frame_requester().schedule_frame(); } @@ -312,6 +364,7 @@ impl App { info: WorktreeInfo, config: Config, forked: bool, + warnings: Vec, result: Result, ) { match result { @@ -335,6 +388,9 @@ impl App { WorktreeSessionTransition::Started }; self.add_worktree_session_message(&info, transition); + for warning in warnings { + self.chat_widget.add_info_message(warning, /*hint*/ None); + } } } Err(err) => { @@ -355,6 +411,7 @@ impl App { info: WorktreeInfo, config: Config, mode: WorktreeSwitchMode, + warnings: Vec, ) { let request_handle = app_server.request_handle(); let remote_cwd_override = app_server.remote_cwd_override().map(Path::to_path_buf); @@ -385,6 +442,7 @@ impl App { info, config, forked, + warnings, result, }); }); @@ -436,9 +494,26 @@ impl App { } fn show_worktree_switching_view(&mut self, tui: &mut tui::Tui, target: String) { + let params = crate::worktree::switching_params( + target.clone(), + tui.frame_requester(), + self.config.animations, + ); + if !self.replace_worktree_view(params) { + self.chat_widget + .show_selection_view(crate::worktree::switching_params( + target, + tui.frame_requester(), + self.config.animations, + )); + } + tui.frame_requester().schedule_frame(); + } + + fn show_worktree_creating_view(&mut self, tui: &mut tui::Tui, branch: String) { self.chat_widget - .show_selection_view(crate::worktree::switching_params( - target, + .show_selection_view(crate::worktree::creating_params( + branch, tui.frame_requester(), self.config.animations, )); diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 03252ad4da54..32900a277a95 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -197,6 +197,9 @@ pub(crate) enum AppEvent { /// Open the managed worktree picker. OpenWorktreePicker, + /// Open the prompt for creating a managed worktree. + OpenWorktreeCreatePrompt, + /// Result of loading worktrees for the managed worktree picker. WorktreesLoaded { cwd: PathBuf, @@ -210,6 +213,12 @@ pub(crate) enum AppEvent { dirty_policy: Option, }, + /// Result of creating or reusing a managed worktree. + WorktreeCreated { + cwd: PathBuf, + result: Result, + }, + /// Switch the TUI into an existing worktree. SwitchToWorktree { target: String, @@ -225,6 +234,7 @@ pub(crate) enum AppEvent { info: codex_worktree::WorktreeInfo, config: Config, forked: bool, + warnings: Vec, result: Result, }, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index aac43984de95..af952e06370d 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -5377,6 +5377,15 @@ impl ChatWidget { self.request_redraw(); } + pub(crate) fn show_bottom_pane_view( + &mut self, + view: Box, + ) { + self.bottom_pane.show_view(view); + self.refresh_plan_mode_nudge(); + self.request_redraw(); + } + pub(crate) fn replace_selection_view_if_active( &mut self, view_id: &'static str, diff --git a/codex-rs/tui/src/snapshots/codex_tui__worktree__tests__worktree_creating.snap b/codex-rs/tui/src/snapshots/codex_tui__worktree__tests__worktree_creating.snap new file mode 100644 index 000000000000..8a4f46bb5d84 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__worktree__tests__worktree_creating.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/worktree.rs +expression: "render_selection(creating_params(\"fcoury/demo\".to_string(),\nFrameRequester::test_dummy(), false), 92)" +--- + + Worktrees + • Creating fcoury/demo... + Codex is creating the worktree before starting the chat in that workspace. + +› Preparing worktree... Codex is creating the worktree before starting the chat in + that workspace. + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/snapshots/codex_tui__worktree__tests__worktree_empty.snap b/codex-rs/tui/src/snapshots/codex_tui__worktree__tests__worktree_empty.snap new file mode 100644 index 000000000000..8219177c4f47 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__worktree__tests__worktree_empty.snap @@ -0,0 +1,10 @@ +--- +source: tui/src/worktree.rs +expression: "render_selection(empty_params(), 84)" +--- + + Worktrees + +› 1. New worktree... Type the branch name for the new worktree. + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/snapshots/codex_tui__worktree__tests__worktree_picker.snap b/codex-rs/tui/src/snapshots/codex_tui__worktree__tests__worktree_picker.snap index ef0b294c81ae..732e83202c96 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__worktree__tests__worktree_picker.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__worktree__tests__worktree_picker.snap @@ -4,10 +4,11 @@ expression: "render_selection(params, 86)" --- Worktrees - Select a worktree to fork this chat into that workspace. + Create a worktree or fork this chat into an existing workspace. Search worktrees -› fcoury/demo (current) Fork this chat into /repo/codex.fcoury-demo +› New worktree... Type the branch name for the new worktree. + fcoury/demo (current) clean · cli · /repo/codex.fcoury-demo codex clean · app · /repo/codex.codex main dirty · git · /repo/codex.main diff --git a/codex-rs/tui/src/worktree.rs b/codex-rs/tui/src/worktree.rs index 4a566dbbfd0d..23fd17a4aff4 100644 --- a/codex-rs/tui/src/worktree.rs +++ b/codex-rs/tui/src/worktree.rs @@ -297,6 +297,33 @@ pub(crate) fn switching_params( } } +pub(crate) fn creating_params( + branch: String, + frame_requester: FrameRequester, + animations_enabled: bool, +) -> SelectionViewParams { + let status = format!("Creating {branch}..."); + let note = + "Codex is creating the worktree before starting the chat in that workspace.".to_string(); + SelectionViewParams { + view_id: Some(WORKTREE_SELECTION_VIEW_ID), + header: Box::new(WorktreeLoadingHeader::new( + frame_requester, + animations_enabled, + status, + note.clone(), + )), + footer_hint: Some(standard_popup_hint_line()), + items: vec![SelectionItem { + name: "Preparing worktree...".to_string(), + description: Some(note), + is_disabled: true, + ..Default::default() + }], + ..Default::default() + } +} + pub(crate) fn empty_params() -> SelectionViewParams { let mut header = ColumnRenderable::new(); header.push(Line::from("Worktrees".bold())); @@ -305,12 +332,7 @@ pub(crate) fn empty_params() -> SelectionViewParams { view_id: Some(WORKTREE_SELECTION_VIEW_ID), header: Box::new(header), footer_hint: Some(standard_popup_hint_line()), - items: vec![SelectionItem { - name: "No worktrees found for this repository.".to_string(), - description: Some("Use /worktree new to create one.".to_string()), - is_disabled: true, - ..Default::default() - }], + items: vec![new_worktree_item()], ..Default::default() } } @@ -338,48 +360,46 @@ pub(crate) fn error_with_summary_params(summary: String, error: String) -> Selec } pub(crate) fn picker_params(entries: Vec, current_cwd: &Path) -> SelectionViewParams { - let items = entries - .into_iter() - .map(|entry| { - let target = entry.branch.clone().unwrap_or_else(|| entry.name.clone()); - let source = source_label(entry.source); - let status = if entry.dirty.is_dirty() { - "dirty" - } else { - "clean" - }; - let description = format!("{status} · {source} · {}", entry.workspace_cwd.display()); - let search_value = Some(format!( - "{} {} {} {}", - target, - entry.name, - source, + let mut items = vec![new_worktree_item()]; + items.extend(entries.into_iter().map(|entry| { + let target = entry.branch.clone().unwrap_or_else(|| entry.name.clone()); + let source = source_label(entry.source); + let status = if entry.dirty.is_dirty() { + "dirty" + } else { + "clean" + }; + let description = format!("{status} · {source} · {}", entry.workspace_cwd.display()); + let search_value = Some(format!( + "{} {} {} {}", + target, + entry.name, + source, + entry.workspace_cwd.display() + )); + SelectionItem { + name: target.clone(), + description: Some(description), + selected_description: Some(format!( + "Fork this chat into {}", entry.workspace_cwd.display() - )); - SelectionItem { - name: target.clone(), - description: Some(description), - selected_description: Some(format!( - "Fork this chat into {}", - entry.workspace_cwd.display() - )), - is_current: paths_match(current_cwd, &entry.workspace_cwd), - actions: vec![Box::new(move |tx| { - tx.send(AppEvent::SwitchToWorktree { - target: target.clone(), - }); - })], - dismiss_on_select: true, - search_value, - ..Default::default() - } - }) - .collect::>(); + )), + is_current: paths_match(current_cwd, &entry.workspace_cwd), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::SwitchToWorktree { + target: target.clone(), + }); + })], + dismiss_on_select: true, + search_value, + ..Default::default() + } + })); let mut header = ColumnRenderable::new(); header.push(Line::from("Worktrees".bold())); header.push(Line::from( - "Select a worktree to fork this chat into that workspace.".dim(), + "Create a worktree or fork this chat into an existing workspace.".dim(), )); SelectionViewParams { @@ -395,6 +415,20 @@ pub(crate) fn picker_params(entries: Vec, current_cwd: &Path) -> S } } +fn new_worktree_item() -> SelectionItem { + SelectionItem { + name: "New worktree...".to_string(), + description: Some("Create a sibling worktree and start this chat there.".to_string()), + selected_description: Some("Type the branch name for the new worktree.".to_string()), + actions: vec![Box::new(|tx| { + tx.send(AppEvent::OpenWorktreeCreatePrompt); + })], + dismiss_on_select: false, + search_value: Some("new worktree create branch".to_string()), + ..Default::default() + } +} + pub(crate) fn dirty_policy_prompt_params( branch: String, base_ref: Option, @@ -619,6 +653,47 @@ mod tests { ); } + #[test] + fn worktree_creating_snapshot() { + insta::assert_snapshot!( + "worktree_creating", + render_selection( + creating_params( + "fcoury/demo".to_string(), + FrameRequester::test_dummy(), + /*animations_enabled*/ false + ), + /*width*/ 92 + ) + ); + } + + #[test] + fn worktree_empty_snapshot() { + insta::assert_snapshot!( + "worktree_empty", + render_selection(empty_params(), /*width*/ 84) + ); + } + + #[test] + fn new_worktree_item_dispatches_create_prompt_event() { + let (tx_raw, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let item = new_worktree_item(); + + assert!( + !item.dismiss_on_select, + "picker should stay behind the branch-name prompt" + ); + (item.actions[0])(&tx); + + assert!(matches!( + rx.try_recv(), + Ok(AppEvent::OpenWorktreeCreatePrompt) + )); + } + #[test] fn worktree_dirty_policy_prompt_snapshot() { insta::assert_snapshot!( From 1c604c0be66e25c05fd56f2fe07699874bdb6dc2 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Thu, 7 May 2026 20:47:52 -0300 Subject: [PATCH 05/12] fix(worktree): name siblings from primary checkout --- codex-rs/worktree/src/manager.rs | 10 +++++++-- codex-rs/worktree/tests/git_backend.rs | 30 ++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/codex-rs/worktree/src/manager.rs b/codex-rs/worktree/src/manager.rs index e0b20248e99d..1492b4ed514c 100644 --- a/codex-rs/worktree/src/manager.rs +++ b/codex-rs/worktree/src/manager.rs @@ -25,7 +25,7 @@ pub fn ensure_worktree(req: WorktreeRequest) -> Result { let branch = req.branch.clone(); let slug = paths::slugify_name(&branch)?; ensure_safe_branch_name(&repo.root, &branch)?; - let worktree_git_root = paths::sibling_worktree_git_root(&repo.root, &branch)?; + let worktree_git_root = paths::sibling_worktree_git_root(&repo.primary_root, &branch)?; let workspace_cwd = worktree_git_root.join(&repo.relative_cwd); if worktree_git_root.exists() { @@ -445,6 +445,7 @@ fn display_branch_or_name(info: &WorktreeInfo) -> &str { struct SourceRepo { root: PathBuf, + primary_root: PathBuf, relative_cwd: PathBuf, common_git_dir: PathBuf, repo_name: String, @@ -462,9 +463,13 @@ impl SourceRepo { let common_git_dir = absolutize(&source_cwd, Path::new(&common_git_dir_raw)) .canonicalize() .unwrap_or_else(|_| absolutize(&source_cwd, Path::new(&common_git_dir_raw))); + let primary_root = primary_worktree_root(&root) + .unwrap_or_else(|_| root.clone()) + .canonicalize() + .unwrap_or_else(|_| root.clone()); let origin = git::stdout(&root, &["remote", "get-url", "origin"]).ok(); let id = paths::repo_fingerprint(&common_git_dir, origin.as_deref()); - let repo_name = root + let repo_name = primary_root .file_name() .context("repository root has no directory name")? .to_string_lossy() @@ -475,6 +480,7 @@ impl SourceRepo { .to_path_buf(); Ok(Self { root, + primary_root, relative_cwd, common_git_dir, repo_name, diff --git a/codex-rs/worktree/tests/git_backend.rs b/codex-rs/worktree/tests/git_backend.rs index 6f9bbc4cdccb..473108c48779 100644 --- a/codex-rs/worktree/tests/git_backend.rs +++ b/codex-rs/worktree/tests/git_backend.rs @@ -94,6 +94,36 @@ fn creates_reuses_lists_and_removes_managed_worktree() -> anyhow::Result<()> { Ok(()) } +#[test] +fn creates_sibling_from_sibling_using_primary_repo_name() -> anyhow::Result<()> { + let fixture = GitFixture::new()?; + let first = codex_worktree::ensure_worktree(WorktreeRequest { + codex_home: fixture.codex_home.path().to_path_buf(), + source_cwd: fixture.repo.path().to_path_buf(), + branch: "fcoury/worktrees".to_string(), + base_ref: None, + dirty_policy: DirtyPolicy::Fail, + })?; + + let second = codex_worktree::ensure_worktree(WorktreeRequest { + codex_home: fixture.codex_home.path().to_path_buf(), + source_cwd: first.info.workspace_cwd, + branch: "fcoury/test".to_string(), + base_ref: None, + dirty_policy: DirtyPolicy::Fail, + })?; + + let canonical_repo = fixture.repo.path().canonicalize()?; + assert_eq!( + second.info.worktree_git_root, + canonical_repo.with_file_name(format!( + "{}.fcoury-test", + canonical_repo.file_name().unwrap().to_string_lossy() + )) + ); + Ok(()) +} + #[test] fn copy_tracked_preserves_staged_and_unstaged_diffs() -> anyhow::Result<()> { let fixture = GitFixture::new()?; From 700f1e4a381f6fbb0c446a4273f9eec6f4e7e2bb Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Fri, 8 May 2026 10:08:26 -0300 Subject: [PATCH 06/12] fix(tui): make current worktree selection a no-op --- codex-rs/tui/src/app/event_dispatch.rs | 4 + codex-rs/tui/src/app/worktree.rs | 5 + codex-rs/tui/src/app_event.rs | 5 + ...tui__worktree__tests__worktree_picker.snap | 4 +- codex-rs/tui/src/worktree.rs | 95 ++++++++++++++++--- 5 files changed, 99 insertions(+), 14 deletions(-) diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 27035083ba22..267233903398 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -215,6 +215,10 @@ impl App { self.begin_switch_to_worktree_target(tui, target); tui.frame_requester().schedule_frame(); } + AppEvent::CurrentWorktreeSelected { target } => { + self.current_worktree_selected(target); + tui.frame_requester().schedule_frame(); + } AppEvent::SwitchToWorktreeAfterLoading { target } => { self.switch_to_worktree_target_after_loading(tui, app_server, target) .await; diff --git a/codex-rs/tui/src/app/worktree.rs b/codex-rs/tui/src/app/worktree.rs index da1538151555..e5d638179c41 100644 --- a/codex-rs/tui/src/app/worktree.rs +++ b/codex-rs/tui/src/app/worktree.rs @@ -183,6 +183,11 @@ impl App { self.defer_switch_to_worktree_target(target); } + pub(super) fn current_worktree_selected(&mut self, target: String) { + self.chat_widget + .add_info_message(format!("Already in worktree {target}."), /*hint*/ None); + } + pub(super) async fn switch_to_worktree_target_after_loading( &mut self, tui: &mut tui::Tui, diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 32900a277a95..72f32f1d8101 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -224,6 +224,11 @@ pub(crate) enum AppEvent { target: String, }, + /// A picker row for the current worktree was selected. + CurrentWorktreeSelected { + target: String, + }, + /// Continue switching into an existing worktree after the loading view has rendered. SwitchToWorktreeAfterLoading { target: String, diff --git a/codex-rs/tui/src/snapshots/codex_tui__worktree__tests__worktree_picker.snap b/codex-rs/tui/src/snapshots/codex_tui__worktree__tests__worktree_picker.snap index 732e83202c96..be9827b7b967 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__worktree__tests__worktree_picker.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__worktree__tests__worktree_picker.snap @@ -7,8 +7,8 @@ expression: "render_selection(params, 86)" Create a worktree or fork this chat into an existing workspace. Search worktrees -› New worktree... Type the branch name for the new worktree. - fcoury/demo (current) clean · cli · /repo/codex.fcoury-demo + New worktree... Create a sibling worktree and start this chat there. +› fcoury/demo (current) Already in this worktree codex clean · app · /repo/codex.codex main dirty · git · /repo/codex.main diff --git a/codex-rs/tui/src/worktree.rs b/codex-rs/tui/src/worktree.rs index 23fd17a4aff4..32a5554f5a2d 100644 --- a/codex-rs/tui/src/worktree.rs +++ b/codex-rs/tui/src/worktree.rs @@ -14,6 +14,7 @@ use ratatui::widgets::WidgetRef; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; use crate::bottom_pane::ColumnWidthMode; +use crate::bottom_pane::SelectionAction; use crate::bottom_pane::SelectionItem; use crate::bottom_pane::SelectionRowDisplay; use crate::bottom_pane::SelectionViewParams; @@ -361,7 +362,8 @@ pub(crate) fn error_with_summary_params(summary: String, error: String) -> Selec pub(crate) fn picker_params(entries: Vec, current_cwd: &Path) -> SelectionViewParams { let mut items = vec![new_worktree_item()]; - items.extend(entries.into_iter().map(|entry| { + let mut initial_selected_idx = None; + items.extend(entries.into_iter().enumerate().map(|(idx, entry)| { let target = entry.branch.clone().unwrap_or_else(|| entry.name.clone()); let source = source_label(entry.source); let status = if entry.dirty.is_dirty() { @@ -369,7 +371,16 @@ pub(crate) fn picker_params(entries: Vec, current_cwd: &Path) -> S } else { "clean" }; + let is_current = is_current_worktree(current_cwd, &entry); + if is_current { + initial_selected_idx = Some(idx + 1); + } let description = format!("{status} · {source} · {}", entry.workspace_cwd.display()); + let selected_description = if is_current { + "Already in this worktree".to_string() + } else { + format!("Fork this chat into {}", entry.workspace_cwd.display()) + }; let search_value = Some(format!( "{} {} {} {}", target, @@ -377,21 +388,28 @@ pub(crate) fn picker_params(entries: Vec, current_cwd: &Path) -> S source, entry.workspace_cwd.display() )); - SelectionItem { - name: target.clone(), - description: Some(description), - selected_description: Some(format!( - "Fork this chat into {}", - entry.workspace_cwd.display() - )), - is_current: paths_match(current_cwd, &entry.workspace_cwd), - actions: vec![Box::new(move |tx| { + let target_for_action = target.clone(); + let actions: Vec = if is_current { + vec![Box::new(move |tx| { + tx.send(AppEvent::CurrentWorktreeSelected { + target: target_for_action.clone(), + }); + })] + } else { + vec![Box::new(move |tx| { tx.send(AppEvent::SwitchToWorktree { - target: target.clone(), + target: target_for_action.clone(), }); - })], + })] + }; + SelectionItem { + name: target, + description: Some(description), + selected_description: Some(selected_description), + actions, dismiss_on_select: true, search_value, + is_current, ..Default::default() } })); @@ -411,6 +429,7 @@ pub(crate) fn picker_params(entries: Vec, current_cwd: &Path) -> S search_placeholder: Some("Search worktrees".to_string()), col_width_mode: ColumnWidthMode::AutoAllRows, row_display: SelectionRowDisplay::SingleLine, + initial_selected_idx, ..Default::default() } } @@ -560,6 +579,20 @@ fn paths_match(a: &Path, b: &Path) -> bool { a == b } +fn is_current_worktree(current_cwd: &Path, entry: &WorktreeInfo) -> bool { + if paths_match(current_cwd, &entry.workspace_cwd) { + return true; + } + let current_cwd = current_cwd + .canonicalize() + .unwrap_or_else(|_| current_cwd.to_path_buf()); + let worktree_root = entry + .worktree_git_root + .canonicalize() + .unwrap_or_else(|_| entry.worktree_git_root.clone()); + current_cwd.starts_with(worktree_root) +} + #[cfg(test)] mod tests { use super::*; @@ -624,6 +657,44 @@ mod tests { insta::assert_snapshot!("worktree_picker", render_selection(params, /*width*/ 86)); } + #[test] + fn worktree_picker_preselects_current_worktree_from_subdirectory() { + let params = picker_params( + vec![ + sample_info("fcoury/demo", WorktreeSource::Cli, /*dirty*/ false), + sample_info( + "fcoury/worktrees", + WorktreeSource::Git, + /*dirty*/ false, + ), + ], + Path::new("/repo/codex.fcoury-worktrees/codex-rs"), + ); + + assert_eq!(params.initial_selected_idx, Some(2)); + } + + #[test] + fn current_worktree_item_dispatches_current_selection_event() { + let (tx_raw, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let params = picker_params( + vec![sample_info( + "fcoury/worktrees", + WorktreeSource::Git, + /*dirty*/ false, + )], + Path::new("/repo/codex.fcoury-worktrees/codex-rs"), + ); + + (params.items[1].actions[0])(&tx); + + assert!(matches!( + rx.try_recv(), + Ok(AppEvent::CurrentWorktreeSelected { target }) if target == "fcoury/worktrees" + )); + } + #[test] fn worktree_loading_snapshot() { insta::assert_snapshot!( From 6e460f31cd89814d685df06a5464e0a1a6406318 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Fri, 8 May 2026 14:13:46 -0300 Subject: [PATCH 07/12] feat(worktree): add move-all dirty policy --- codex-rs/cli/src/main.rs | 15 +++++ ...__tests__worktree_dirty_policy_prompt.snap | 8 ++- codex-rs/tui/src/worktree.rs | 41 +++++++++---- .../utils/cli/src/worktree_dirty_cli_arg.rs | 1 + codex-rs/worktree/src/dirty.rs | 57 ++++++++++++++++--- codex-rs/worktree/tests/git_backend.rs | 50 ++++++++++++++++ 6 files changed, 150 insertions(+), 22 deletions(-) diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index fa2cc62a52dc..faad516de189 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -789,6 +789,7 @@ fn dirty_policy_from_cli(arg: WorktreeDirtyCliArg) -> DirtyPolicy { WorktreeDirtyCliArg::Ignore => DirtyPolicy::Ignore, WorktreeDirtyCliArg::CopyTracked => DirtyPolicy::CopyTracked, WorktreeDirtyCliArg::CopyAll => DirtyPolicy::CopyAll, + WorktreeDirtyCliArg::MoveAll => DirtyPolicy::MoveAll, } } @@ -2418,6 +2419,20 @@ mod tests { ); } + #[test] + fn top_level_worktree_flags_parse_move_all_dirty_policy() { + let cli = MultitoolCli::try_parse_from([ + "codex", + "--worktree", + "parser-fix", + "--worktree-dirty", + "move-all", + ]) + .expect("worktree flags should parse"); + + assert_eq!(cli.interactive.worktree_dirty, WorktreeDirtyCliArg::MoveAll); + } + #[test] fn worktree_subcommand_parses() { let cli = MultitoolCli::try_parse_from(["codex", "worktree", "list", "--all", "--json"]) diff --git a/codex-rs/tui/src/snapshots/codex_tui__worktree__tests__worktree_dirty_policy_prompt.snap b/codex-rs/tui/src/snapshots/codex_tui__worktree__tests__worktree_dirty_policy_prompt.snap index 548fda3de9a1..afa9226de21f 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__worktree__tests__worktree_dirty_policy_prompt.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__worktree__tests__worktree_dirty_policy_prompt.snap @@ -6,9 +6,11 @@ expression: "render_selection(dirty_policy_prompt_params(\"fcoury/demo\".to_stri Source checkout has uncommitted changes Choose what to carry into the new worktree. -› 1. Fail Cancel creation and leave the source checkout unchanged. - 2. Ignore Create from the requested base without copying local changes. +› 1. Move all Move tracked changes and untracked files; leave the source + checkout clean. + 2. Copy all Copy tracked changes and untracked files. 3. Copy tracked Copy staged and unstaged tracked changes. - 4. Copy all Copy tracked changes and untracked files. + 4. Ignore Create from the requested base without copying local changes. + 5. Fail Cancel creation and leave the source checkout unchanged. Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/worktree.rs b/codex-rs/tui/src/worktree.rs index 32a5554f5a2d..e01450f3d319 100644 --- a/codex-rs/tui/src/worktree.rs +++ b/codex-rs/tui/src/worktree.rs @@ -236,7 +236,11 @@ fn parse_dirty_policy(value: &str) -> Result { "ignore" => Ok(DirtyPolicy::Ignore), "copy-tracked" => Ok(DirtyPolicy::CopyTracked), "copy-all" => Ok(DirtyPolicy::CopyAll), - _ => Err("Dirty mode must be one of: fail, ignore, copy-tracked, copy-all.".to_string()), + "move-all" => Ok(DirtyPolicy::MoveAll), + _ => Err( + "Dirty mode must be one of: fail, ignore, copy-tracked, copy-all, move-all." + .to_string(), + ), } } @@ -479,14 +483,14 @@ pub(crate) fn dirty_policy_prompt_params( footer_hint: Some(standard_popup_hint_line()), items: vec![ item( - "Fail", - "Cancel creation and leave the source checkout unchanged.", - DirtyPolicy::Fail, + "Move all", + "Move tracked changes and untracked files; leave the source checkout clean.", + DirtyPolicy::MoveAll, ), item( - "Ignore", - "Create from the requested base without copying local changes.", - DirtyPolicy::Ignore, + "Copy all", + "Copy tracked changes and untracked files.", + DirtyPolicy::CopyAll, ), item( "Copy tracked", @@ -494,9 +498,14 @@ pub(crate) fn dirty_policy_prompt_params( DirtyPolicy::CopyTracked, ), item( - "Copy all", - "Copy tracked changes and untracked files.", - DirtyPolicy::CopyAll, + "Ignore", + "Create from the requested base without copying local changes.", + DirtyPolicy::Ignore, + ), + item( + "Fail", + "Cancel creation and leave the source checkout unchanged.", + DirtyPolicy::Fail, ), ], ..Default::default() @@ -622,6 +631,18 @@ mod tests { ); } + #[test] + fn parse_new_with_move_all_dirty_policy() { + assert_eq!( + parse_worktree_slash_args("new fcoury/demo --dirty move-all"), + Ok(WorktreeSlashAction::Create { + branch: "fcoury/demo".to_string(), + base_ref: /*base_ref*/ None, + dirty_policy: Some(DirtyPolicy::MoveAll), + }) + ); + } + #[test] fn parse_switch_aliases_move() { assert_eq!( diff --git a/codex-rs/utils/cli/src/worktree_dirty_cli_arg.rs b/codex-rs/utils/cli/src/worktree_dirty_cli_arg.rs index b7843957dd60..eafd46a5dd83 100644 --- a/codex-rs/utils/cli/src/worktree_dirty_cli_arg.rs +++ b/codex-rs/utils/cli/src/worktree_dirty_cli_arg.rs @@ -8,4 +8,5 @@ pub enum WorktreeDirtyCliArg { Ignore, CopyTracked, CopyAll, + MoveAll, } diff --git a/codex-rs/worktree/src/dirty.rs b/codex-rs/worktree/src/dirty.rs index 5d2a13bda44e..8f2e00ef5581 100644 --- a/codex-rs/worktree/src/dirty.rs +++ b/codex-rs/worktree/src/dirty.rs @@ -16,6 +16,7 @@ pub enum DirtyPolicy { Ignore, CopyTracked, CopyAll, + MoveAll, } #[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] @@ -61,14 +62,14 @@ pub fn validate_dirty_policy_before_create( DirtyPolicy::CopyTracked => { if state.has_untracked_files { Ok(vec![ - "untracked files were left in the source checkout; use --worktree-dirty copy-all to copy them" + "untracked files were left in the source checkout; use --worktree-dirty copy-all or move-all to carry them" .to_string(), ]) } else { Ok(Vec::new()) } } - DirtyPolicy::CopyAll => Ok(Vec::new()), + DirtyPolicy::CopyAll | DirtyPolicy::MoveAll => Ok(Vec::new()), } } @@ -90,12 +91,19 @@ pub fn apply_dirty_policy_after_create( copy_untracked_files(source_root, worktree_root)?; Ok(()) } + DirtyPolicy::MoveAll => { + let untracked_paths = untracked_paths(source_root)?; + apply_tracked_diff(source_root, worktree_root)?; + copy_untracked_files_at_paths(source_root, worktree_root, &untracked_paths)?; + clean_source_after_move(source_root, &untracked_paths)?; + Ok(()) + } } } fn bail_for_dirty_source() -> Result { anyhow::bail!( - "source checkout has uncommitted changes; use --worktree-dirty ignore, copy-tracked, or copy-all" + "source checkout has uncommitted changes; use --worktree-dirty ignore, copy-tracked, copy-all, or move-all" ); } @@ -119,18 +127,35 @@ fn apply_tracked_diff(source_root: &Path, worktree_root: &Path) -> Result<()> { } fn copy_untracked_files(source_root: &Path, worktree_root: &Path) -> Result<()> { + let paths = untracked_paths(source_root)?; + copy_untracked_files_at_paths(source_root, worktree_root, &paths) +} + +fn untracked_paths(source_root: &Path) -> Result> { let output = git::bytes( source_root, &["ls-files", "--others", "--exclude-standard", "-z"], )?; - for raw_path in output + output .split(|byte| *byte == 0) .filter(|path| !path.is_empty()) - { - let relative_path = PathBuf::from(String::from_utf8_lossy(raw_path).into_owned()); - ensure_safe_relative_path(&relative_path)?; - let source = source_root.join(&relative_path); - let destination = worktree_root.join(&relative_path); + .map(|raw_path| { + let relative_path = PathBuf::from(String::from_utf8_lossy(raw_path).into_owned()); + ensure_safe_relative_path(&relative_path)?; + Ok(relative_path) + }) + .collect() +} + +fn copy_untracked_files_at_paths( + source_root: &Path, + worktree_root: &Path, + paths: &[PathBuf], +) -> Result<()> { + for relative_path in paths { + ensure_safe_relative_path(relative_path)?; + let source = source_root.join(relative_path); + let destination = worktree_root.join(relative_path); let metadata = fs::symlink_metadata(&source)?; if let Some(parent) = destination.parent() { fs::create_dir_all(parent)?; @@ -145,6 +170,20 @@ fn copy_untracked_files(source_root: &Path, worktree_root: &Path) -> Result<()> Ok(()) } +fn clean_source_after_move(source_root: &Path, untracked_paths: &[PathBuf]) -> Result<()> { + git::status(source_root, &["reset", "--hard", "HEAD"]) + .context("failed to clean tracked changes from source checkout after move")?; + for relative_path in untracked_paths { + fs::remove_file(source_root.join(relative_path)).with_context(|| { + format!( + "failed to remove moved untracked path {} from source checkout", + relative_path.display() + ) + })?; + } + Ok(()) +} + fn ensure_safe_relative_path(path: &Path) -> Result<()> { if path.is_absolute() { anyhow::bail!( diff --git a/codex-rs/worktree/tests/git_backend.rs b/codex-rs/worktree/tests/git_backend.rs index 473108c48779..637fa0c93994 100644 --- a/codex-rs/worktree/tests/git_backend.rs +++ b/codex-rs/worktree/tests/git_backend.rs @@ -156,6 +156,56 @@ fn copy_tracked_preserves_staged_and_unstaged_diffs() -> anyhow::Result<()> { Ok(()) } +#[test] +fn move_all_transfers_dirty_state_and_cleans_source_checkout() -> anyhow::Result<()> { + let fixture = GitFixture::new()?; + fs::write(fixture.repo.path().join(".gitignore"), "ignored.txt\n")?; + run_git(fixture.repo.path(), &["add", ".gitignore"])?; + run_git(fixture.repo.path(), &["commit", "-m", "ignore fixture"])?; + fs::write(fixture.repo.path().join("staged.txt"), "staged changed\n")?; + run_git(fixture.repo.path(), &["add", "staged.txt"])?; + fs::write( + fixture.repo.path().join("unstaged.txt"), + "unstaged changed\n", + )?; + fs::write(fixture.repo.path().join("untracked.txt"), "untracked\n")?; + fs::write(fixture.repo.path().join("ignored.txt"), "ignored\n")?; + + let resolution = codex_worktree::ensure_worktree(WorktreeRequest { + codex_home: fixture.codex_home.path().to_path_buf(), + source_cwd: fixture.repo.path().to_path_buf(), + branch: "move-dirty".to_string(), + base_ref: /*base_ref*/ None, + dirty_policy: DirtyPolicy::MoveAll, + })?; + + assert_eq!( + git( + &resolution.info.worktree_git_root, + &["diff", "--cached", "--name-only"] + )?, + "staged.txt" + ); + assert_eq!( + git(&resolution.info.worktree_git_root, &["diff", "--name-only"])?, + "unstaged.txt" + ); + assert_eq!( + fs::read_to_string(resolution.info.worktree_git_root.join("untracked.txt"))?, + "untracked\n" + ); + assert!( + !resolution + .info + .worktree_git_root + .join("ignored.txt") + .exists() + ); + assert_eq!(git(fixture.repo.path(), &["status", "--short"])?, ""); + assert!(fixture.repo.path().join("ignored.txt").exists()); + Ok(()) +} + #[test] fn refuses_sibling_path_collision_for_different_branch() -> anyhow::Result<()> { let fixture = GitFixture::new()?; From 93317c151dacce015d68722d133e09ff404026aa Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Sat, 9 May 2026 12:52:13 -0300 Subject: [PATCH 08/12] feat(worktree): fill remaining worktree gaps --- codex-rs/cli/src/main.rs | 57 ++ codex-rs/tui/src/app/event_dispatch.rs | 14 +- codex-rs/tui/src/app/worktree.rs | 301 ++++-- codex-rs/tui/src/app_event.rs | 5 + codex-rs/tui/src/app_server_session.rs | 4 + .../tui/src/bottom_pane/custom_prompt_view.rs | 16 +- codex-rs/tui/src/lib.rs | 1 + codex-rs/tui/src/remote_worktree.rs | 868 ++++++++++++++++++ ...__tests__worktree_dirty_policy_prompt.snap | 8 +- codex-rs/tui/src/worktree.rs | 20 +- .../utils/cli/src/worktree_dirty_cli_arg.rs | 1 + codex-rs/worktree/src/dirty.rs | 158 +++- codex-rs/worktree/src/lib.rs | 5 + codex-rs/worktree/src/manager.rs | 15 +- codex-rs/worktree/tests/git_backend.rs | 105 +++ 15 files changed, 1435 insertions(+), 143 deletions(-) create mode 100644 codex-rs/tui/src/remote_worktree.rs diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index faad516de189..e4a8f861e45a 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -352,6 +352,9 @@ struct WorktreeCli { #[derive(Debug, clap::Subcommand)] enum WorktreeSubcommand { + /// Create or reuse a Codex-managed worktree for the current repository. + Create(WorktreeCreateCommand), + /// List Codex-managed worktrees for the current repository. List(WorktreeListCommand), @@ -376,6 +379,20 @@ struct WorktreeListCommand { json: bool, } +#[derive(Debug, Args)] +struct WorktreeCreateCommand { + /// Branch name for the managed worktree. + branch: String, + + /// Base ref for a newly created managed worktree. + #[arg(long = "base", value_name = "REF")] + base_ref: Option, + + /// How to handle uncommitted source checkout changes when creating the worktree. + #[arg(long = "dirty", value_enum, default_value_t = WorktreeDirtyCliArg::Fail)] + dirty: WorktreeDirtyCliArg, +} + #[derive(Debug, Args)] struct WorktreePathCommand { /// Managed worktree name or slug. @@ -789,6 +806,7 @@ fn dirty_policy_from_cli(arg: WorktreeDirtyCliArg) -> DirtyPolicy { WorktreeDirtyCliArg::Ignore => DirtyPolicy::Ignore, WorktreeDirtyCliArg::CopyTracked => DirtyPolicy::CopyTracked, WorktreeDirtyCliArg::CopyAll => DirtyPolicy::CopyAll, + WorktreeDirtyCliArg::MoveTracked => DirtyPolicy::MoveTracked, WorktreeDirtyCliArg::MoveAll => DirtyPolicy::MoveAll, } } @@ -796,6 +814,19 @@ fn dirty_policy_from_cli(arg: WorktreeDirtyCliArg) -> DirtyPolicy { fn run_worktree_command(cli: WorktreeCli) -> anyhow::Result<()> { let codex_home = find_codex_home()?.to_path_buf(); match cli.subcommand { + WorktreeSubcommand::Create(command) => { + let resolution = codex_worktree::ensure_worktree(WorktreeRequest { + codex_home, + source_cwd: std::env::current_dir()?, + branch: command.branch, + base_ref: command.base_ref, + dirty_policy: dirty_policy_from_cli(command.dirty), + })?; + for warning in &resolution.warnings { + eprintln!("warning: {}", warning.message); + } + println!("{}", resolution.info.workspace_cwd.display()); + } WorktreeSubcommand::List(command) => { let source_cwd = if command.all { None @@ -2433,6 +2464,32 @@ mod tests { assert_eq!(cli.interactive.worktree_dirty, WorktreeDirtyCliArg::MoveAll); } + #[test] + fn worktree_create_subcommand_parses() { + let cli = MultitoolCli::try_parse_from([ + "codex", + "worktree", + "create", + "parser-fix", + "--base", + "origin/main", + "--dirty", + "move-tracked", + ]) + .expect("worktree create should parse"); + + let Some(Subcommand::Worktree(WorktreeCli { + subcommand: WorktreeSubcommand::Create(command), + })) = cli.subcommand + else { + panic!("expected worktree create subcommand"); + }; + + assert_eq!(command.branch, "parser-fix"); + assert_eq!(command.base_ref.as_deref(), Some("origin/main")); + assert_eq!(command.dirty, WorktreeDirtyCliArg::MoveTracked); + } + #[test] fn worktree_subcommand_parses() { let cli = MultitoolCli::try_parse_from(["codex", "worktree", "list", "--all", "--json"]) diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 267233903398..ff3372139fc5 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -188,13 +188,17 @@ impl App { tui.frame_requester().schedule_frame(); } AppEvent::OpenWorktreePicker => { - self.open_worktree_picker(tui); + self.open_worktree_picker(tui, app_server).await; tui.frame_requester().schedule_frame(); } AppEvent::OpenWorktreeCreatePrompt => { self.open_worktree_create_prompt(); tui.frame_requester().schedule_frame(); } + AppEvent::OpenWorktreeBaseRefPrompt { branch } => { + self.open_worktree_base_ref_prompt(branch); + tui.frame_requester().schedule_frame(); + } AppEvent::WorktreesLoaded { cwd, result } => { self.on_worktrees_loaded(cwd, result); tui.frame_requester().schedule_frame(); @@ -204,7 +208,8 @@ impl App { base_ref, dirty_policy, } => { - self.create_worktree_and_switch(tui, branch, base_ref, dirty_policy); + self.create_worktree_and_switch(tui, app_server, branch, base_ref, dirty_policy) + .await; tui.frame_requester().schedule_frame(); } AppEvent::WorktreeCreated { cwd, result } => { @@ -238,7 +243,7 @@ impl App { tui.frame_requester().schedule_frame(); } AppEvent::ShowWorktreePath { target } => { - self.show_worktree_path(target); + self.show_worktree_path(app_server, target).await; tui.frame_requester().schedule_frame(); } AppEvent::RemoveWorktree { @@ -247,7 +252,8 @@ impl App { delete_branch, confirmed, } => { - self.remove_worktree(target, force, delete_branch, confirmed); + self.remove_worktree(app_server, target, force, delete_branch, confirmed) + .await; tui.frame_requester().schedule_frame(); } AppEvent::BeginInitialHistoryReplayBuffer => { diff --git a/codex-rs/tui/src/app/worktree.rs b/codex-rs/tui/src/app/worktree.rs index e5d638179c41..d1b1d8c560a3 100644 --- a/codex-rs/tui/src/app/worktree.rs +++ b/codex-rs/tui/src/app/worktree.rs @@ -1,5 +1,6 @@ //! App-layer handlers for the worktree TUI flow. +use anyhow::Context; use codex_protocol::ThreadId; use codex_worktree::DirtyPolicy; use codex_worktree::WorktreeInfo; @@ -39,29 +40,28 @@ impl WorktreeSessionTransition { } impl App { - pub(super) fn open_worktree_picker(&mut self, tui: &mut tui::Tui) { - if self.remote_app_server_url.is_some() { - self.chat_widget.add_error_message( - "/worktree is not supported for remote sessions yet.".to_string(), - ); - return; - } + pub(super) async fn open_worktree_picker( + &mut self, + tui: &mut tui::Tui, + app_server: &AppServerSession, + ) { self.chat_widget .show_selection_view(crate::worktree::loading_params( tui.frame_requester(), self.config.animations, )); - self.fetch_worktrees_for_picker(); - } - - pub(super) fn open_worktree_create_prompt(&mut self) { if self.remote_app_server_url.is_some() { - self.chat_widget.add_error_message( - "/worktree is not supported for remote sessions yet.".to_string(), - ); - return; + let result = self + .list_current_repo_worktrees_remote(app_server) + .await + .map_err(|err| err.to_string()); + self.on_worktrees_loaded(self.session_workspace_cwd(app_server).to_path_buf(), result); + } else { + self.fetch_worktrees_for_picker(); } + } + pub(super) fn open_worktree_create_prompt(&mut self) { let tx = self.app_event_tx.clone(); let view = CustomPromptView::new( "New worktree".to_string(), @@ -70,9 +70,29 @@ impl App { /*context_label*/ Some("Creates a sibling worktree and starts this chat there.".to_string()), Box::new(move |branch: String| { - tx.send(AppEvent::CreateWorktreeAndSwitch { + tx.send(AppEvent::OpenWorktreeBaseRefPrompt { branch: branch.trim().to_string(), - base_ref: None, + }); + }), + ); + self.chat_widget.show_bottom_pane_view(Box::new(view)); + } + + pub(super) fn open_worktree_base_ref_prompt(&mut self, branch: String) { + let tx = self.app_event_tx.clone(); + let view = CustomPromptView::new_allow_empty( + "Base ref".to_string(), + "Optional base ref; leave blank for default".to_string(), + /*initial_text*/ String::new(), + /*context_label*/ + Some(format!( + "Create {branch} from this ref, or leave blank for the default." + )), + Box::new(move |base_ref: String| { + let base_ref = base_ref.trim(); + tx.send(AppEvent::CreateWorktreeAndSwitch { + branch: branch.clone(), + base_ref: (!base_ref.is_empty()).then(|| base_ref.to_string()), dirty_policy: None, }); }), @@ -85,33 +105,28 @@ impl App { cwd: PathBuf, result: Result, String>, ) { - if cwd.as_path() != self.config.cwd.as_path() { + if self.remote_app_server_url.is_none() && cwd.as_path() != self.config.cwd.as_path() { return; } let params = match result { Ok(entries) if entries.is_empty() => crate::worktree::empty_params(), - Ok(entries) => crate::worktree::picker_params(entries, self.config.cwd.as_path()), + Ok(entries) => crate::worktree::picker_params(entries, cwd.as_path()), Err(err) => crate::worktree::error_params(err), }; self.replace_worktree_view(params); } - pub(super) fn create_worktree_and_switch( + pub(super) async fn create_worktree_and_switch( &mut self, tui: &mut tui::Tui, + app_server: &AppServerSession, branch: String, base_ref: Option, dirty_policy: Option, ) { - if self.remote_app_server_url.is_some() { - self.chat_widget.add_error_message( - "/worktree is not supported for remote sessions yet.".to_string(), - ); - return; - } let dirty_policy = match dirty_policy { Some(policy) => policy, - None => match codex_worktree::dirty_state(self.config.cwd.as_path()) { + None => match self.source_worktree_dirty_state(app_server).await { Ok(state) if state.is_dirty() => { let params = crate::worktree::dirty_policy_prompt_params(branch, base_ref); self.chat_widget.show_selection_view(params); @@ -127,13 +142,16 @@ impl App { }; self.show_worktree_creating_view(tui, branch.clone()); - self.spawn_worktree_create_request(WorktreeRequest { - codex_home: self.config.codex_home.to_path_buf(), - source_cwd: self.config.cwd.to_path_buf(), - branch, - base_ref, - dirty_policy, - }); + self.spawn_worktree_create_request( + app_server, + WorktreeRequest { + codex_home: self.config.codex_home.to_path_buf(), + source_cwd: self.session_workspace_cwd(app_server).to_path_buf(), + branch, + base_ref, + dirty_policy, + }, + ); } pub(super) async fn on_worktree_created( @@ -143,7 +161,7 @@ impl App { cwd: PathBuf, result: Result, ) { - if cwd.as_path() != self.config.cwd.as_path() { + if cwd.as_path() != self.session_workspace_cwd(app_server) { return; } let resolution = match result { @@ -173,12 +191,6 @@ impl App { } pub(super) fn begin_switch_to_worktree_target(&mut self, tui: &mut tui::Tui, target: String) { - if self.remote_app_server_url.is_some() { - self.chat_widget.add_error_message( - "/worktree is not supported for remote sessions yet.".to_string(), - ); - return; - } self.show_worktree_switching_view(tui, target.clone()); self.defer_switch_to_worktree_target(target); } @@ -194,13 +206,10 @@ impl App { app_server: &mut AppServerSession, target: String, ) { - if self.remote_app_server_url.is_some() { - self.chat_widget.add_error_message( - "/worktree is not supported for remote sessions yet.".to_string(), - ); - return; - } - let entries = match self.list_current_repo_worktrees() { + let entries = match self + .list_current_repo_worktrees_for_session(app_server) + .await + { Ok(entries) => entries, Err(err) => { self.show_worktree_error("Failed to list worktrees.".to_string(), err.to_string()); @@ -218,14 +227,15 @@ impl App { .await; } - pub(super) fn show_worktree_path(&mut self, target: String) { - if self.remote_app_server_url.is_some() { - self.chat_widget.add_error_message( - "/worktree is not supported for remote sessions yet.".to_string(), - ); - return; - } - match self.list_current_repo_worktrees() { + pub(super) async fn show_worktree_path( + &mut self, + app_server: &AppServerSession, + target: String, + ) { + match self + .list_current_repo_worktrees_for_session(app_server) + .await + { Ok(entries) => match crate::worktree::find_worktree(&entries, &target) { Ok(info) => { self.chat_widget.add_info_message( @@ -242,20 +252,18 @@ impl App { } } - pub(super) fn remove_worktree( + pub(super) async fn remove_worktree( &mut self, + app_server: &AppServerSession, target: String, force: bool, delete_branch: bool, confirmed: bool, ) { - if self.remote_app_server_url.is_some() { - self.chat_widget.add_error_message( - "/worktree is not supported for remote sessions yet.".to_string(), - ); - return; - } - let entries = match self.list_current_repo_worktrees() { + let entries = match self + .list_current_repo_worktrees_for_session(app_server) + .await + { Ok(entries) => entries, Err(err) => { self.chat_widget @@ -283,13 +291,33 @@ impl App { return; } - match codex_worktree::remove_worktree(WorktreeRemoveRequest { - codex_home: self.config.codex_home.to_path_buf(), - source_cwd: Some(self.config.cwd.to_path_buf()), - name_or_path: target.clone(), - force, - delete_branch, - }) { + let result = if self.remote_app_server_url.is_some() { + let Some(runner) = self.workspace_command_runner.clone() else { + self.chat_widget.add_error_message( + "Remote worktree removal is unavailable because the workspace command runner is missing." + .to_string(), + ); + return; + }; + crate::remote_worktree::remove_worktree( + &runner, + &app_server.request_handle(), + self.session_workspace_cwd(app_server), + &target, + force, + delete_branch, + ) + .await + } else { + codex_worktree::remove_worktree(WorktreeRemoveRequest { + codex_home: self.config.codex_home.to_path_buf(), + source_cwd: Some(self.session_workspace_cwd(app_server).to_path_buf()), + name_or_path: target.clone(), + force, + delete_branch, + }) + }; + match result { Ok(result) => { let mut message = format!("Removed worktree {}", result.removed_path.display()); if let Some(branch) = result.deleted_branch { @@ -312,6 +340,67 @@ impl App { }) } + async fn list_current_repo_worktrees_for_session( + &self, + app_server: &AppServerSession, + ) -> anyhow::Result> { + if self.remote_app_server_url.is_some() { + self.list_current_repo_worktrees_remote(app_server).await + } else { + self.list_current_repo_worktrees() + } + } + + async fn list_current_repo_worktrees_remote( + &self, + app_server: &AppServerSession, + ) -> anyhow::Result> { + let runner = self + .workspace_command_runner + .clone() + .context("remote worktree operations require a workspace command runner")?; + crate::remote_worktree::list_current_repo_worktrees( + &runner, + &app_server.request_handle(), + self.session_workspace_cwd(app_server), + ) + .await + } + + async fn source_worktree_dirty_state( + &self, + app_server: &AppServerSession, + ) -> anyhow::Result { + if self.remote_app_server_url.is_some() { + let runner = self + .workspace_command_runner + .clone() + .context("remote worktree operations require a workspace command runner")?; + crate::remote_worktree::source_dirty_state( + &runner, + self.session_workspace_cwd(app_server), + ) + .await + } else { + codex_worktree::dirty_state(self.config.cwd.as_path()) + } + } + + fn session_workspace_cwd<'a>(&'a self, app_server: &'a AppServerSession) -> &'a Path { + if self.remote_app_server_url.is_some() { + app_server + .remote_cwd_override() + .or_else(|| { + self.primary_session_configured + .as_ref() + .map(|session| session.cwd.as_path()) + }) + .unwrap_or(self.config.cwd.as_path()) + } else { + self.config.cwd.as_path() + } + } + fn fetch_worktrees_for_picker(&mut self) { let query = WorktreeListQuery { codex_home: self.config.codex_home.to_path_buf(), @@ -326,13 +415,38 @@ impl App { }); } - fn spawn_worktree_create_request(&self, request: WorktreeRequest) { + fn spawn_worktree_create_request( + &self, + app_server: &AppServerSession, + request: WorktreeRequest, + ) { let cwd = request.source_cwd.clone(); let app_event_tx = self.app_event_tx.clone(); - tokio::task::spawn_blocking(move || { - let result = codex_worktree::ensure_worktree(request).map_err(|err| err.to_string()); - app_event_tx.send(AppEvent::WorktreeCreated { cwd, result }); - }); + if self.remote_app_server_url.is_some() { + let Some(runner) = self.workspace_command_runner.clone() else { + app_event_tx.send(AppEvent::WorktreeCreated { + cwd, + result: Err( + "remote worktree operations require a workspace command runner".to_string(), + ), + }); + return; + }; + let request_handle = app_server.request_handle(); + tokio::spawn(async move { + let result = + crate::remote_worktree::ensure_worktree(&runner, &request_handle, request) + .await + .map_err(|err| err.to_string()); + app_event_tx.send(AppEvent::WorktreeCreated { cwd, result }); + }); + } else { + tokio::task::spawn_blocking(move || { + let result = + codex_worktree::ensure_worktree(request).map_err(|err| err.to_string()); + app_event_tx.send(AppEvent::WorktreeCreated { cwd, result }); + }); + } } async fn switch_to_worktree_info( @@ -342,17 +456,21 @@ impl App { info: WorktreeInfo, warnings: Vec, ) { - let mut config = match self - .rebuild_config_for_cwd(info.workspace_cwd.clone()) - .await - { - Ok(config) => config, - Err(err) => { - self.show_worktree_error( - "Failed to rebuild configuration for worktree.".to_string(), - err.to_string(), - ); - return; + let mut config = if app_server.is_remote() { + self.config.clone() + } else { + match self + .rebuild_config_for_cwd(info.workspace_cwd.clone()) + .await + { + Ok(config) => config, + Err(err) => { + self.show_worktree_error( + "Failed to rebuild configuration for worktree.".to_string(), + err.to_string(), + ); + return; + } } }; self.apply_runtime_policy_overrides(&mut config); @@ -387,6 +505,9 @@ impl App { err.to_string(), ); } else { + if app_server.is_remote() { + app_server.set_remote_cwd_override(Some(info.workspace_cwd.clone())); + } let transition = if forked { WorktreeSessionTransition::Forked } else { @@ -419,7 +540,11 @@ impl App { warnings: Vec, ) { let request_handle = app_server.request_handle(); - let remote_cwd_override = app_server.remote_cwd_override().map(Path::to_path_buf); + let remote_cwd_override = if app_server.is_remote() { + Some(info.workspace_cwd.clone()) + } else { + app_server.remote_cwd_override().map(Path::to_path_buf) + }; let app_event_tx = self.app_event_tx.clone(); tokio::spawn(async move { let forked = matches!(mode, WorktreeSwitchMode::Fork(_)); diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 72f32f1d8101..890802920445 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -200,6 +200,11 @@ pub(crate) enum AppEvent { /// Open the prompt for creating a managed worktree. OpenWorktreeCreatePrompt, + /// Open the optional base-ref prompt for creating a managed worktree. + OpenWorktreeBaseRefPrompt { + branch: String, + }, + /// Result of loading worktrees for the managed worktree picker. WorktreesLoaded { cwd: PathBuf, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 543bd4988437..08752630fcfa 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -191,6 +191,10 @@ impl AppServerSession { self.remote_cwd_override.as_deref() } + pub(crate) fn set_remote_cwd_override(&mut self, remote_cwd_override: Option) { + self.remote_cwd_override = remote_cwd_override; + } + pub(crate) fn is_remote(&self) -> bool { matches!(self.client, AppServerClient::Remote(_)) } diff --git a/codex-rs/tui/src/bottom_pane/custom_prompt_view.rs b/codex-rs/tui/src/bottom_pane/custom_prompt_view.rs index bce9528fde71..a706463fc144 100644 --- a/codex-rs/tui/src/bottom_pane/custom_prompt_view.rs +++ b/codex-rs/tui/src/bottom_pane/custom_prompt_view.rs @@ -31,6 +31,7 @@ pub(crate) struct CustomPromptView { placeholder: String, context_label: Option, on_submit: PromptSubmitted, + allow_empty: bool, // UI state textarea: TextArea, @@ -57,11 +58,24 @@ impl CustomPromptView { placeholder, context_label, on_submit, + allow_empty: false, textarea, textarea_state: RefCell::new(TextAreaState::default()), completion: None, } } + + pub(crate) fn new_allow_empty( + title: String, + placeholder: String, + initial_text: String, + context_label: Option, + on_submit: PromptSubmitted, + ) -> Self { + let mut view = Self::new(title, placeholder, initial_text, context_label, on_submit); + view.allow_empty = true; + view + } } impl BottomPaneView for CustomPromptView { @@ -78,7 +92,7 @@ impl BottomPaneView for CustomPromptView { .. } => { let text = self.textarea.text().trim().to_string(); - if !text.is_empty() { + if self.allow_empty || !text.is_empty() { (self.on_submit)(text); self.completion = Some(ViewCompletion::Accepted); } diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 48de5bbfc4ae..55b0c4ef1a27 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -179,6 +179,7 @@ mod tui; mod ui_consts; pub(crate) mod update_action; pub use update_action::UpdateAction; +mod remote_worktree; mod worktree; mod worktree_labels; #[cfg(not(debug_assertions))] diff --git a/codex-rs/tui/src/remote_worktree.rs b/codex-rs/tui/src/remote_worktree.rs new file mode 100644 index 000000000000..fbf3331e4087 --- /dev/null +++ b/codex-rs/tui/src/remote_worktree.rs @@ -0,0 +1,868 @@ +use std::path::Path; +use std::path::PathBuf; + +use anyhow::Context; +use anyhow::Result; +use base64::Engine; +use base64::engine::general_purpose::STANDARD; +use codex_app_server_client::AppServerRequestHandle; +use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::FsCopyParams; +use codex_app_server_protocol::FsCopyResponse; +use codex_app_server_protocol::FsCreateDirectoryParams; +use codex_app_server_protocol::FsCreateDirectoryResponse; +use codex_app_server_protocol::FsGetMetadataParams; +use codex_app_server_protocol::FsGetMetadataResponse; +use codex_app_server_protocol::FsReadFileParams; +use codex_app_server_protocol::FsReadFileResponse; +use codex_app_server_protocol::FsRemoveParams; +use codex_app_server_protocol::FsRemoveResponse; +use codex_app_server_protocol::FsWriteFileParams; +use codex_app_server_protocol::FsWriteFileResponse; +use codex_app_server_protocol::RequestId; +use codex_utils_absolute_path::AbsolutePathBuf; +use codex_worktree::DirtyPolicy; +use codex_worktree::DirtyState; +use codex_worktree::WorktreeInfo; +use codex_worktree::WorktreeLocation; +use codex_worktree::WorktreeMetadata; +use codex_worktree::WorktreeRemoveResult; +use codex_worktree::WorktreeRequest; +use codex_worktree::WorktreeResolution; +use codex_worktree::WorktreeSource; +use codex_worktree::WorktreeThreadMetadata; +use codex_worktree::WorktreeWarning; +use uuid::Uuid; + +use crate::workspace_command::WorkspaceCommand; +use crate::workspace_command::WorkspaceCommandRunner; + +pub(crate) async fn list_current_repo_worktrees( + runner: &WorkspaceCommandRunner, + request_handle: &AppServerRequestHandle, + source_cwd: &Path, +) -> Result> { + let repo = RemoteSourceRepo::resolve(runner, source_cwd).await?; + let worktrees = parse_worktree_list( + &git_stdout(runner, &repo.root, &["worktree", "list", "--porcelain"]).await?, + ); + let mut infos = Vec::new(); + for entry in worktrees { + infos.push( + info_from_existing_worktree(runner, request_handle, &entry.path, entry.branch, &repo) + .await?, + ); + } + Ok(infos) +} + +pub(crate) async fn source_dirty_state( + runner: &WorkspaceCommandRunner, + source_cwd: &Path, +) -> Result { + let repo = RemoteSourceRepo::resolve(runner, source_cwd).await?; + dirty_state(runner, &repo.root).await +} + +pub(crate) async fn ensure_worktree( + runner: &WorkspaceCommandRunner, + request_handle: &AppServerRequestHandle, + req: WorktreeRequest, +) -> Result { + let repo = RemoteSourceRepo::resolve(runner, &req.source_cwd).await?; + let branch = req.branch.clone(); + git_status( + runner, + &repo.root, + &["check-ref-format", "--branch", &branch], + ) + .await?; + let worktree_git_root = codex_worktree::sibling_worktree_git_root(&repo.primary_root, &branch)?; + let workspace_cwd = worktree_git_root.join(&repo.relative_cwd); + + if path_exists(request_handle, &worktree_git_root).await? { + let metadata = read_metadata(runner, request_handle, &worktree_git_root) + .await? + .context("managed worktree path already exists but is not owned by Codex")?; + if metadata.branch.as_deref() != Some(branch.as_str()) && metadata.name != branch { + anyhow::bail!( + "managed worktree path {} is already used by {}; choose a different branch name", + worktree_git_root.display(), + metadata.branch.as_deref().unwrap_or(metadata.name.as_str()) + ); + } + let info = info_from_existing_worktree( + runner, + request_handle, + &worktree_git_root, + Some(branch), + &repo, + ) + .await?; + return Ok(WorktreeResolution { + reused: true, + info, + warnings: Vec::new(), + }); + } + + let worktrees = parse_worktree_list( + &git_stdout(runner, &repo.root, &["worktree", "list", "--porcelain"]).await?, + ); + if let Some(existing) = worktrees + .iter() + .find(|entry| entry.branch.as_deref() == Some(branch.as_str())) + && existing.path != worktree_git_root + { + anyhow::bail!( + "branch {branch} is already checked out at {}; remove that worktree first", + existing.path.display() + ); + } + + let warnings = + validate_dirty_policy_before_create(runner, &repo.root, req.dirty_policy).await?; + create_directory( + request_handle, + worktree_git_root + .parent() + .context("managed worktree path has no parent")?, + ) + .await?; + let branch_exists = git_status_result( + runner, + &repo.root, + &[ + "show-ref", + "--verify", + "--quiet", + &format!("refs/heads/{branch}"), + ], + ) + .await? + .success(); + let has_head = git_status_result(runner, &repo.root, &["rev-parse", "--verify", "HEAD"]) + .await? + .success(); + if branch_exists { + git_status( + runner, + &repo.root, + &[ + "worktree", + "add", + &worktree_git_root.to_string_lossy(), + &branch, + ], + ) + .await?; + } else if req.base_ref.is_none() && !has_head { + git_status( + runner, + &repo.root, + &[ + "worktree", + "add", + "--orphan", + "-b", + &branch, + &worktree_git_root.to_string_lossy(), + ], + ) + .await?; + } else { + let base_ref = req.base_ref.as_deref().unwrap_or("HEAD"); + git_status( + runner, + &repo.root, + &[ + "worktree", + "add", + "-b", + &branch, + &worktree_git_root.to_string_lossy(), + base_ref, + ], + ) + .await?; + } + + apply_dirty_policy_after_create( + runner, + request_handle, + &repo.root, + &worktree_git_root, + req.dirty_policy, + ) + .await?; + let dirty = dirty_state(runner, &worktree_git_root).await?; + let head = git_stdout_result(runner, &worktree_git_root, &["rev-parse", "HEAD"]) + .await? + .success() + .then_some(async { git_stdout(runner, &worktree_git_root, &["rev-parse", "HEAD"]).await }); + let head = match head { + Some(future) => Some(future.await?), + None => None, + }; + let info_metadata_path = + metadata_path(runner, &worktree_git_root, "codex-worktree.json").await?; + let mut info = WorktreeInfo { + id: repo.id.clone(), + name: branch.clone(), + slug: codex_worktree::slugify_name(&branch)?, + source: WorktreeSource::Cli, + location: WorktreeLocation::Sibling, + repo_name: repo.repo_name.clone(), + repo_root: repo.root.clone(), + common_git_dir: repo.common_git_dir.clone(), + worktree_git_root: worktree_git_root.clone(), + workspace_cwd, + original_relative_cwd: repo.relative_cwd.clone(), + branch: Some(branch), + head, + owner_thread_id: None, + metadata_path: info_metadata_path, + dirty, + }; + write_json( + request_handle, + &metadata_path(runner, &worktree_git_root, "codex-thread.json").await?, + &WorktreeThreadMetadata { + version: 1, + owner_thread_id: None, + }, + ) + .await?; + let metadata = WorktreeMetadata::from_info(&info, repo.root); + write_json(request_handle, &info.metadata_path, &metadata).await?; + info.owner_thread_id = metadata.owner_thread_id; + Ok(WorktreeResolution { + reused: false, + info, + warnings: warnings + .into_iter() + .map(|message| WorktreeWarning { message }) + .collect(), + }) +} + +pub(crate) async fn remove_worktree( + runner: &WorkspaceCommandRunner, + request_handle: &AppServerRequestHandle, + source_cwd: &Path, + target: &str, + force: bool, + delete_branch: bool, +) -> Result { + let entries = list_current_repo_worktrees(runner, request_handle, source_cwd).await?; + let info = entries + .into_iter() + .find(|entry| { + entry.branch.as_deref() == Some(target) || entry.name == target || entry.slug == target + }) + .context("no managed worktree matched target")?; + let metadata = read_metadata(runner, request_handle, &info.worktree_git_root) + .await? + .context("refusing to remove a worktree not managed by Codex")?; + if metadata.source != WorktreeSource::Cli { + anyhow::bail!("refusing to remove a worktree not managed by Codex CLI"); + } + if info.dirty.is_dirty() && !force { + anyhow::bail!( + "refusing to remove dirty worktree {}; use --force to override", + info.worktree_git_root.display() + ); + } + let repo = RemoteSourceRepo::resolve(runner, source_cwd).await?; + let mut args = vec!["worktree", "remove"]; + if force { + args.push("--force"); + } + let target_path = info.worktree_git_root.to_string_lossy().to_string(); + args.push(&target_path); + git_status(runner, &repo.primary_root, &args).await?; + let mut deleted_branch = None; + if delete_branch && let Some(branch) = info.branch { + let delete_flag = if force { "-D" } else { "-d" }; + git_status( + runner, + &repo.primary_root, + &["branch", delete_flag, &branch], + ) + .await?; + deleted_branch = Some(branch); + } + Ok(WorktreeRemoveResult { + removed_path: info.worktree_git_root, + deleted_branch, + }) +} + +#[derive(Clone)] +struct RemoteSourceRepo { + root: PathBuf, + primary_root: PathBuf, + relative_cwd: PathBuf, + common_git_dir: PathBuf, + repo_name: String, + id: String, +} + +impl RemoteSourceRepo { + async fn resolve(runner: &WorkspaceCommandRunner, source_cwd: &Path) -> Result { + let root = + PathBuf::from(git_stdout(runner, source_cwd, &["rev-parse", "--show-toplevel"]).await?); + let common_git_dir_raw = + git_stdout(runner, source_cwd, &["rev-parse", "--git-common-dir"]).await?; + let common_git_dir = absolutize(source_cwd, Path::new(&common_git_dir_raw)); + let primary_root = parse_worktree_list( + &git_stdout(runner, &root, &["worktree", "list", "--porcelain"]).await?, + ) + .into_iter() + .next() + .map(|entry| entry.path) + .context("git did not report a primary worktree")?; + let origin = git_stdout_result(runner, &root, &["remote", "get-url", "origin"]).await?; + let origin = origin.success().then_some(origin.stdout.trim().to_string()); + let id = codex_worktree::repo_fingerprint(&common_git_dir, origin.as_deref()); + let repo_name = primary_root + .file_name() + .context("repository root has no directory name")? + .to_string_lossy() + .to_string(); + let relative_cwd = source_cwd + .strip_prefix(&root) + .unwrap_or_else(|_| Path::new("")) + .to_path_buf(); + Ok(Self { + root, + primary_root, + relative_cwd, + common_git_dir, + repo_name, + id, + }) + } +} + +async fn info_from_existing_worktree( + runner: &WorkspaceCommandRunner, + request_handle: &AppServerRequestHandle, + worktree_git_root: &Path, + fallback_branch: Option, + repo: &RemoteSourceRepo, +) -> Result { + let metadata = read_metadata(runner, request_handle, worktree_git_root).await?; + let branch = git_stdout_result( + runner, + worktree_git_root, + &["symbolic-ref", "--quiet", "--short", "HEAD"], + ) + .await? + .success() + .then_some(async { + git_stdout( + runner, + worktree_git_root, + &["symbolic-ref", "--quiet", "--short", "HEAD"], + ) + .await + }); + let branch = match branch { + Some(future) => Some(future.await?), + None => fallback_branch, + }; + let head = git_stdout_result(runner, worktree_git_root, &["rev-parse", "HEAD"]) + .await? + .success() + .then_some(async { git_stdout(runner, worktree_git_root, &["rev-parse", "HEAD"]).await }); + let head = match head { + Some(future) => Some(future.await?), + None => None, + }; + let dirty = dirty_state(runner, worktree_git_root) + .await + .unwrap_or_default(); + let name = metadata + .as_ref() + .map(|metadata| metadata.name.clone()) + .or_else(|| branch.clone()) + .unwrap_or_else(|| repo.repo_name.clone()); + let slug = metadata + .as_ref() + .map(|metadata| metadata.slug.clone()) + .unwrap_or_else(|| name.replace(['/', '\\'], "-")); + Ok(WorktreeInfo { + id: metadata + .as_ref() + .map(|metadata| metadata.repo_id.clone()) + .unwrap_or_else(|| repo.id.clone()), + name, + slug, + source: metadata + .as_ref() + .map(|metadata| metadata.source) + .unwrap_or(WorktreeSource::Git), + location: metadata + .as_ref() + .map(|metadata| metadata.location) + .unwrap_or(WorktreeLocation::External), + repo_name: repo.repo_name.clone(), + repo_root: repo.root.clone(), + common_git_dir: repo.common_git_dir.clone(), + worktree_git_root: worktree_git_root.to_path_buf(), + workspace_cwd: metadata + .as_ref() + .map(|metadata| metadata.workspace_cwd.clone()) + .unwrap_or_else(|| worktree_git_root.to_path_buf()), + original_relative_cwd: metadata + .as_ref() + .map(|metadata| metadata.original_relative_cwd.clone()) + .unwrap_or_default(), + branch, + head, + owner_thread_id: metadata.and_then(|metadata| metadata.owner_thread_id), + metadata_path: metadata_path(runner, worktree_git_root, "codex-worktree.json").await?, + dirty, + }) +} + +async fn dirty_state(runner: &WorkspaceCommandRunner, root: &Path) -> Result { + Ok(DirtyState { + has_staged_changes: !git_stdout(runner, root, &["diff", "--cached", "--name-only", "-z"]) + .await? + .is_empty(), + has_unstaged_changes: !git_stdout(runner, root, &["diff", "--name-only", "-z"]) + .await? + .is_empty(), + has_untracked_files: !git_stdout( + runner, + root, + &["ls-files", "--others", "--exclude-standard", "-z"], + ) + .await? + .is_empty(), + }) +} + +async fn validate_dirty_policy_before_create( + runner: &WorkspaceCommandRunner, + root: &Path, + policy: DirtyPolicy, +) -> Result> { + let state = dirty_state(runner, root).await?; + if !state.is_dirty() { + return Ok(Vec::new()); + } + match policy { + DirtyPolicy::Fail => anyhow::bail!( + "source checkout has uncommitted changes; use --worktree-dirty ignore, copy-tracked, copy-all, move-tracked, or move-all" + ), + DirtyPolicy::Ignore => Ok(vec![ + "source checkout has uncommitted changes; the new worktree was created without them" + .to_string(), + ]), + DirtyPolicy::CopyTracked | DirtyPolicy::MoveTracked if state.has_untracked_files => Ok(vec![ + "untracked files were left in the source checkout; use --worktree-dirty copy-all or move-all to carry them" + .to_string(), + ]), + DirtyPolicy::CopyTracked + | DirtyPolicy::CopyAll + | DirtyPolicy::MoveTracked + | DirtyPolicy::MoveAll => Ok(Vec::new()), + } +} + +async fn apply_dirty_policy_after_create( + runner: &WorkspaceCommandRunner, + request_handle: &AppServerRequestHandle, + source_root: &Path, + worktree_root: &Path, + policy: DirtyPolicy, +) -> Result<()> { + let state = dirty_state(runner, source_root).await?; + if !state.is_dirty() { + return Ok(()); + } + let plan = RemoteTransferPlan::capture(runner, source_root).await?; + match policy { + DirtyPolicy::Fail | DirtyPolicy::Ignore => Ok(()), + DirtyPolicy::CopyTracked => { + plan.apply_tracked_diff(runner, request_handle, worktree_root) + .await + } + DirtyPolicy::CopyAll => { + plan.apply_tracked_diff(runner, request_handle, worktree_root) + .await?; + plan.copy_untracked(request_handle, source_root, worktree_root) + .await + } + DirtyPolicy::MoveTracked => { + plan.apply_tracked_diff(runner, request_handle, worktree_root) + .await?; + plan.clean_source_after_move( + runner, + request_handle, + source_root, + /*move_untracked*/ false, + ) + .await + } + DirtyPolicy::MoveAll => { + plan.apply_tracked_diff(runner, request_handle, worktree_root) + .await?; + plan.copy_untracked(request_handle, source_root, worktree_root) + .await?; + plan.clean_source_after_move( + runner, + request_handle, + source_root, + /*move_untracked*/ true, + ) + .await + } + } +} + +struct RemoteTransferPlan { + staged_diff: String, + unstaged_diff: String, + tracked_paths: Vec, + untracked_paths: Vec, +} + +impl RemoteTransferPlan { + async fn capture(runner: &WorkspaceCommandRunner, source_root: &Path) -> Result { + Ok(Self { + staged_diff: git_stdout(runner, source_root, &["diff", "--cached", "--binary"]).await?, + unstaged_diff: git_stdout(runner, source_root, &["diff", "--binary"]).await?, + tracked_paths: tracked_paths(runner, source_root).await?, + untracked_paths: untracked_paths(runner, source_root).await?, + }) + } + + async fn apply_tracked_diff( + &self, + runner: &WorkspaceCommandRunner, + request_handle: &AppServerRequestHandle, + worktree_root: &Path, + ) -> Result<()> { + if !self.staged_diff.is_empty() { + apply_patch_file( + runner, + request_handle, + worktree_root, + "staged", + &self.staged_diff, + &["apply", "--index", "--binary"], + ) + .await?; + } + if !self.unstaged_diff.is_empty() { + apply_patch_file( + runner, + request_handle, + worktree_root, + "unstaged", + &self.unstaged_diff, + &["apply", "--binary"], + ) + .await?; + } + Ok(()) + } + + async fn copy_untracked( + &self, + request_handle: &AppServerRequestHandle, + source_root: &Path, + worktree_root: &Path, + ) -> Result<()> { + for relative_path in &self.untracked_paths { + if let Some(parent) = worktree_root.join(relative_path).parent() { + create_directory(request_handle, parent).await?; + } + fs_copy( + request_handle, + &source_root.join(relative_path), + &worktree_root.join(relative_path), + ) + .await?; + } + Ok(()) + } + + async fn clean_source_after_move( + &self, + runner: &WorkspaceCommandRunner, + request_handle: &AppServerRequestHandle, + source_root: &Path, + move_untracked: bool, + ) -> Result<()> { + if git_status_result(runner, source_root, &["rev-parse", "--verify", "HEAD"]) + .await? + .success() + { + git_status(runner, source_root, &["reset", "--hard", "HEAD"]).await?; + } else { + git_status(runner, source_root, &["read-tree", "--empty"]).await?; + for relative_path in &self.tracked_paths { + fs_remove(request_handle, &source_root.join(relative_path)).await?; + } + } + if move_untracked { + for relative_path in &self.untracked_paths { + fs_remove(request_handle, &source_root.join(relative_path)).await?; + } + } + Ok(()) + } +} + +async fn apply_patch_file( + runner: &WorkspaceCommandRunner, + request_handle: &AppServerRequestHandle, + cwd: &Path, + label: &str, + contents: &str, + git_args: &[&str], +) -> Result<()> { + let patch_path = metadata_path( + runner, + cwd, + &format!("codex-worktree-{label}-{}.patch", Uuid::new_v4()), + ) + .await?; + fs_write(request_handle, &patch_path, contents.as_bytes()).await?; + let mut args = git_args.to_vec(); + let patch_arg = patch_path.to_string_lossy().to_string(); + args.push(&patch_arg); + let result = git_status(runner, cwd, &args).await; + let _ = fs_remove(request_handle, &patch_path).await; + result +} + +async fn tracked_paths(runner: &WorkspaceCommandRunner, root: &Path) -> Result> { + let mut paths = paths_from_nul_separated( + &git_stdout(runner, root, &["diff", "--cached", "--name-only", "-z"]).await?, + ); + paths.extend(paths_from_nul_separated( + &git_stdout(runner, root, &["diff", "--name-only", "-z"]).await?, + )); + paths.sort(); + paths.dedup(); + Ok(paths) +} + +async fn untracked_paths(runner: &WorkspaceCommandRunner, root: &Path) -> Result> { + Ok(paths_from_nul_separated( + &git_stdout( + runner, + root, + &["ls-files", "--others", "--exclude-standard", "-z"], + ) + .await?, + )) +} + +fn paths_from_nul_separated(output: &str) -> Vec { + output + .split('\0') + .filter(|path| !path.is_empty()) + .map(PathBuf::from) + .collect() +} + +async fn read_metadata( + runner: &WorkspaceCommandRunner, + request_handle: &AppServerRequestHandle, + worktree_root: &Path, +) -> Result> { + let path = metadata_path(runner, worktree_root, "codex-worktree.json").await?; + if !path_exists(request_handle, &path).await? { + return Ok(None); + } + let contents = fs_read(request_handle, &path).await?; + Ok(Some(serde_json::from_slice(&contents)?)) +} + +async fn write_json( + request_handle: &AppServerRequestHandle, + path: &Path, + value: &T, +) -> Result<()> { + fs_write(request_handle, path, &serde_json::to_vec_pretty(value)?).await +} + +async fn metadata_path( + runner: &WorkspaceCommandRunner, + root: &Path, + name: &str, +) -> Result { + Ok(absolutize( + root, + Path::new(&git_stdout(runner, root, &["rev-parse", "--git-path", name]).await?), + )) +} + +fn absolutize(cwd: &Path, path: &Path) -> PathBuf { + if path.is_absolute() { + path.to_path_buf() + } else { + cwd.join(path) + } +} + +#[derive(Debug)] +struct GitWorktreeEntry { + path: PathBuf, + branch: Option, +} + +fn parse_worktree_list(output: &str) -> Vec { + let mut entries = Vec::new(); + let mut path = None; + let mut branch = None; + for line in output.lines().chain(std::iter::once("")) { + if line.is_empty() { + if let Some(path) = path.take() { + entries.push(GitWorktreeEntry { + path, + branch: branch.take(), + }); + } + continue; + } + if let Some(raw_path) = line.strip_prefix("worktree ") { + path = Some(PathBuf::from(raw_path)); + } else if let Some(raw_branch) = line.strip_prefix("branch ") { + branch = Some(raw_branch.trim_start_matches("refs/heads/").to_string()); + } + } + entries +} + +async fn git_stdout(runner: &WorkspaceCommandRunner, cwd: &Path, args: &[&str]) -> Result { + let output = git_stdout_result(runner, cwd, args).await?; + if !output.success() { + anyhow::bail!("git {} failed: {}", args.join(" "), output.stderr.trim()); + } + Ok(output.stdout.trim_end().to_string()) +} + +async fn git_stdout_result( + runner: &WorkspaceCommandRunner, + cwd: &Path, + args: &[&str], +) -> Result { + let argv = std::iter::once("git") + .chain(args.iter().copied()) + .collect::>(); + Ok(runner.run(WorkspaceCommand::new(argv).cwd(cwd)).await?) +} + +async fn git_status(runner: &WorkspaceCommandRunner, cwd: &Path, args: &[&str]) -> Result<()> { + let output = git_status_result(runner, cwd, args).await?; + if !output.success() { + anyhow::bail!("git {} failed: {}", args.join(" "), output.stderr.trim()); + } + Ok(()) +} + +async fn git_status_result( + runner: &WorkspaceCommandRunner, + cwd: &Path, + args: &[&str], +) -> Result { + git_stdout_result(runner, cwd, args).await +} + +fn absolute_path(path: &Path) -> Result { + AbsolutePathBuf::from_absolute_path(path).map_err(Into::into) +} + +async fn path_exists(request_handle: &AppServerRequestHandle, path: &Path) -> Result { + let result: Result = request_handle + .request_typed(ClientRequest::FsGetMetadata { + request_id: RequestId::String(format!("worktree-fs-meta-{}", Uuid::new_v4())), + params: FsGetMetadataParams { + path: absolute_path(path)?, + }, + }) + .await; + Ok(result.is_ok()) +} + +async fn create_directory(request_handle: &AppServerRequestHandle, path: &Path) -> Result<()> { + let _: FsCreateDirectoryResponse = request_handle + .request_typed(ClientRequest::FsCreateDirectory { + request_id: RequestId::String(format!("worktree-fs-mkdir-{}", Uuid::new_v4())), + params: FsCreateDirectoryParams { + path: absolute_path(path)?, + recursive: Some(true), + }, + }) + .await?; + Ok(()) +} + +async fn fs_read(request_handle: &AppServerRequestHandle, path: &Path) -> Result> { + let response: FsReadFileResponse = request_handle + .request_typed(ClientRequest::FsReadFile { + request_id: RequestId::String(format!("worktree-fs-read-{}", Uuid::new_v4())), + params: FsReadFileParams { + path: absolute_path(path)?, + }, + }) + .await?; + Ok(STANDARD.decode(response.data_base64)?) +} + +async fn fs_write( + request_handle: &AppServerRequestHandle, + path: &Path, + bytes: &[u8], +) -> Result<()> { + let _: FsWriteFileResponse = request_handle + .request_typed(ClientRequest::FsWriteFile { + request_id: RequestId::String(format!("worktree-fs-write-{}", Uuid::new_v4())), + params: FsWriteFileParams { + path: absolute_path(path)?, + data_base64: STANDARD.encode(bytes), + }, + }) + .await?; + Ok(()) +} + +async fn fs_copy( + request_handle: &AppServerRequestHandle, + source_path: &Path, + destination_path: &Path, +) -> Result<()> { + let _: FsCopyResponse = request_handle + .request_typed(ClientRequest::FsCopy { + request_id: RequestId::String(format!("worktree-fs-copy-{}", Uuid::new_v4())), + params: FsCopyParams { + source_path: absolute_path(source_path)?, + destination_path: absolute_path(destination_path)?, + recursive: false, + }, + }) + .await?; + Ok(()) +} + +async fn fs_remove(request_handle: &AppServerRequestHandle, path: &Path) -> Result<()> { + let _: FsRemoveResponse = request_handle + .request_typed(ClientRequest::FsRemove { + request_id: RequestId::String(format!("worktree-fs-remove-{}", Uuid::new_v4())), + params: FsRemoveParams { + path: absolute_path(path)?, + recursive: Some(false), + force: Some(true), + }, + }) + .await?; + Ok(()) +} diff --git a/codex-rs/tui/src/snapshots/codex_tui__worktree__tests__worktree_dirty_policy_prompt.snap b/codex-rs/tui/src/snapshots/codex_tui__worktree__tests__worktree_dirty_policy_prompt.snap index afa9226de21f..e4d7b84ac31e 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__worktree__tests__worktree_dirty_policy_prompt.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__worktree__tests__worktree_dirty_policy_prompt.snap @@ -9,8 +9,10 @@ expression: "render_selection(dirty_policy_prompt_params(\"fcoury/demo\".to_stri › 1. Move all Move tracked changes and untracked files; leave the source checkout clean. 2. Copy all Copy tracked changes and untracked files. - 3. Copy tracked Copy staged and unstaged tracked changes. - 4. Ignore Create from the requested base without copying local changes. - 5. Fail Cancel creation and leave the source checkout unchanged. + 3. Move tracked Move staged and unstaged tracked changes; leave untracked + files behind. + 4. Copy tracked Copy staged and unstaged tracked changes. + 5. Ignore Create from the requested base without copying local changes. + 6. Fail Cancel creation and leave the source checkout unchanged. Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/worktree.rs b/codex-rs/tui/src/worktree.rs index e01450f3d319..41a07b21a1b5 100644 --- a/codex-rs/tui/src/worktree.rs +++ b/codex-rs/tui/src/worktree.rs @@ -236,9 +236,10 @@ fn parse_dirty_policy(value: &str) -> Result { "ignore" => Ok(DirtyPolicy::Ignore), "copy-tracked" => Ok(DirtyPolicy::CopyTracked), "copy-all" => Ok(DirtyPolicy::CopyAll), + "move-tracked" => Ok(DirtyPolicy::MoveTracked), "move-all" => Ok(DirtyPolicy::MoveAll), _ => Err( - "Dirty mode must be one of: fail, ignore, copy-tracked, copy-all, move-all." + "Dirty mode must be one of: fail, ignore, copy-tracked, copy-all, move-tracked, move-all." .to_string(), ), } @@ -492,6 +493,11 @@ pub(crate) fn dirty_policy_prompt_params( "Copy tracked changes and untracked files.", DirtyPolicy::CopyAll, ), + item( + "Move tracked", + "Move staged and unstaged tracked changes; leave untracked files behind.", + DirtyPolicy::MoveTracked, + ), item( "Copy tracked", "Copy staged and unstaged tracked changes.", @@ -643,6 +649,18 @@ mod tests { ); } + #[test] + fn parse_new_with_move_tracked_dirty_policy() { + assert_eq!( + parse_worktree_slash_args("new fcoury/demo --dirty move-tracked"), + Ok(WorktreeSlashAction::Create { + branch: "fcoury/demo".to_string(), + base_ref: /*base_ref*/ None, + dirty_policy: Some(DirtyPolicy::MoveTracked), + }) + ); + } + #[test] fn parse_switch_aliases_move() { assert_eq!( diff --git a/codex-rs/utils/cli/src/worktree_dirty_cli_arg.rs b/codex-rs/utils/cli/src/worktree_dirty_cli_arg.rs index eafd46a5dd83..cf907714335e 100644 --- a/codex-rs/utils/cli/src/worktree_dirty_cli_arg.rs +++ b/codex-rs/utils/cli/src/worktree_dirty_cli_arg.rs @@ -8,5 +8,6 @@ pub enum WorktreeDirtyCliArg { Ignore, CopyTracked, CopyAll, + MoveTracked, MoveAll, } diff --git a/codex-rs/worktree/src/dirty.rs b/codex-rs/worktree/src/dirty.rs index 8f2e00ef5581..8fd3cce88693 100644 --- a/codex-rs/worktree/src/dirty.rs +++ b/codex-rs/worktree/src/dirty.rs @@ -16,9 +16,18 @@ pub enum DirtyPolicy { Ignore, CopyTracked, CopyAll, + MoveTracked, MoveAll, } +#[derive(Debug)] +struct TransferPlan { + staged_diff: Vec, + unstaged_diff: Vec, + tracked_paths: Vec, + untracked_paths: Vec, +} + #[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct DirtyState { @@ -59,7 +68,7 @@ pub fn validate_dirty_policy_before_create( "source checkout has uncommitted changes; the new worktree was created without them" .to_string(), ]), - DirtyPolicy::CopyTracked => { + DirtyPolicy::CopyTracked | DirtyPolicy::MoveTracked => { if state.has_untracked_files { Ok(vec![ "untracked files were left in the source checkout; use --worktree-dirty copy-all or move-all to carry them" @@ -85,17 +94,33 @@ pub fn apply_dirty_policy_after_create( match policy { DirtyPolicy::Fail | DirtyPolicy::Ignore => Ok(()), - DirtyPolicy::CopyTracked => apply_tracked_diff(source_root, worktree_root), + DirtyPolicy::CopyTracked => { + let plan = TransferPlan::capture(source_root)?; + plan.apply_tracked_diff(worktree_root) + } DirtyPolicy::CopyAll => { - apply_tracked_diff(source_root, worktree_root)?; - copy_untracked_files(source_root, worktree_root)?; + let plan = TransferPlan::capture(source_root)?; + plan.apply_tracked_diff(worktree_root)?; + copy_untracked_files_at_paths(source_root, worktree_root, &plan.untracked_paths)?; + Ok(()) + } + DirtyPolicy::MoveTracked => { + let plan = TransferPlan::capture(source_root)?; + plan.apply_tracked_diff(worktree_root)?; + plan.clean_source_after_move(source_root, /*move_untracked*/ false) + .with_context(|| { + "worktree already contains transferred changes, but failed to clean the source checkout after move" + })?; Ok(()) } DirtyPolicy::MoveAll => { - let untracked_paths = untracked_paths(source_root)?; - apply_tracked_diff(source_root, worktree_root)?; - copy_untracked_files_at_paths(source_root, worktree_root, &untracked_paths)?; - clean_source_after_move(source_root, &untracked_paths)?; + let plan = TransferPlan::capture(source_root)?; + plan.apply_tracked_diff(worktree_root)?; + copy_untracked_files_at_paths(source_root, worktree_root, &plan.untracked_paths)?; + plan.clean_source_after_move(source_root, /*move_untracked*/ true) + .with_context(|| { + "worktree already contains transferred changes, but failed to clean the source checkout after move" + })?; Ok(()) } } @@ -103,39 +128,71 @@ pub fn apply_dirty_policy_after_create( fn bail_for_dirty_source() -> Result { anyhow::bail!( - "source checkout has uncommitted changes; use --worktree-dirty ignore, copy-tracked, copy-all, or move-all" + "source checkout has uncommitted changes; use --worktree-dirty ignore, copy-tracked, copy-all, move-tracked, or move-all" ); } -fn apply_tracked_diff(source_root: &Path, worktree_root: &Path) -> Result<()> { - let staged = git::bytes(source_root, &["diff", "--cached", "--binary"])?; - let unstaged = git::bytes(source_root, &["diff", "--binary"])?; - - if !staged.is_empty() { - git::status_with_stdin( - worktree_root, - &["apply", "--index", "--binary", "-"], - &staged, - ) - .context("failed to apply staged changes to worktree")?; +impl TransferPlan { + fn capture(source_root: &Path) -> Result { + Ok(Self { + staged_diff: git::bytes(source_root, &["diff", "--cached", "--binary"])?, + unstaged_diff: git::bytes(source_root, &["diff", "--binary"])?, + tracked_paths: tracked_paths(source_root)?, + untracked_paths: untracked_paths(source_root)?, + }) } - if !unstaged.is_empty() { - git::status_with_stdin(worktree_root, &["apply", "--binary", "-"], &unstaged) + + fn apply_tracked_diff(&self, worktree_root: &Path) -> Result<()> { + if !self.staged_diff.is_empty() { + git::status_with_stdin( + worktree_root, + &["apply", "--index", "--binary", "-"], + &self.staged_diff, + ) + .context("failed to apply staged changes to worktree")?; + } + if !self.unstaged_diff.is_empty() { + git::status_with_stdin( + worktree_root, + &["apply", "--binary", "-"], + &self.unstaged_diff, + ) .context("failed to apply unstaged changes to worktree")?; + } + Ok(()) + } + + fn clean_source_after_move(&self, source_root: &Path, move_untracked: bool) -> Result<()> { + if has_head(source_root) { + git::status(source_root, &["reset", "--hard", "HEAD"]) + .context("failed to clean tracked changes from source checkout after move")?; + } else { + git::status(source_root, &["read-tree", "--empty"]) + .context("failed to clear unborn source index after move")?; + for relative_path in &self.tracked_paths { + remove_file_if_present(source_root, relative_path, "tracked")?; + } + } + if move_untracked { + for relative_path in &self.untracked_paths { + remove_file_if_present(source_root, relative_path, "untracked")?; + } + } + Ok(()) } - Ok(()) } -fn copy_untracked_files(source_root: &Path, worktree_root: &Path) -> Result<()> { - let paths = untracked_paths(source_root)?; - copy_untracked_files_at_paths(source_root, worktree_root, &paths) +fn tracked_paths(source_root: &Path) -> Result> { + let staged = git::bytes(source_root, &["diff", "--cached", "--name-only", "-z"])?; + let unstaged = git::bytes(source_root, &["diff", "--name-only", "-z"])?; + let mut paths = paths_from_nul_separated(&staged)?; + paths.extend(paths_from_nul_separated(&unstaged)?); + paths.sort(); + paths.dedup(); + Ok(paths) } -fn untracked_paths(source_root: &Path) -> Result> { - let output = git::bytes( - source_root, - &["ls-files", "--others", "--exclude-standard", "-z"], - )?; +fn paths_from_nul_separated(output: &[u8]) -> Result> { output .split(|byte| *byte == 0) .filter(|path| !path.is_empty()) @@ -147,6 +204,31 @@ fn untracked_paths(source_root: &Path) -> Result> { .collect() } +fn has_head(source_root: &Path) -> bool { + git::status(source_root, &["rev-parse", "--verify", "HEAD"]).is_ok() +} + +fn remove_file_if_present(source_root: &Path, relative_path: &Path, kind: &str) -> Result<()> { + match fs::remove_file(source_root.join(relative_path)) { + Ok(()) => Ok(()), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(err) => Err(err).with_context(|| { + format!( + "failed to remove moved {kind} path {} from source checkout", + relative_path.display() + ) + }), + } +} + +fn untracked_paths(source_root: &Path) -> Result> { + let output = git::bytes( + source_root, + &["ls-files", "--others", "--exclude-standard", "-z"], + )?; + paths_from_nul_separated(&output) +} + fn copy_untracked_files_at_paths( source_root: &Path, worktree_root: &Path, @@ -170,20 +252,6 @@ fn copy_untracked_files_at_paths( Ok(()) } -fn clean_source_after_move(source_root: &Path, untracked_paths: &[PathBuf]) -> Result<()> { - git::status(source_root, &["reset", "--hard", "HEAD"]) - .context("failed to clean tracked changes from source checkout after move")?; - for relative_path in untracked_paths { - fs::remove_file(source_root.join(relative_path)).with_context(|| { - format!( - "failed to remove moved untracked path {} from source checkout", - relative_path.display() - ) - })?; - } - Ok(()) -} - fn ensure_safe_relative_path(path: &Path) -> Result<()> { if path.is_absolute() { anyhow::bail!( diff --git a/codex-rs/worktree/src/lib.rs b/codex-rs/worktree/src/lib.rs index 3ca1a70f41a1..8c863028a0a9 100644 --- a/codex-rs/worktree/src/lib.rs +++ b/codex-rs/worktree/src/lib.rs @@ -16,11 +16,16 @@ pub use manager::ensure_worktree; pub use manager::list_worktrees; pub use manager::remove_worktree; pub use manager::resolve_worktree; +pub use metadata::WorktreeMetadata; +pub use metadata::WorktreeThreadMetadata; pub use metadata::bind_thread; pub use metadata::read_worktree_metadata; pub use metadata::write_worktree_metadata; pub use paths::codex_worktrees_root; pub use paths::is_managed_worktree_path; +pub use paths::repo_fingerprint; +pub use paths::sibling_worktree_git_root; +pub use paths::slugify_name; #[derive(Debug, Clone, PartialEq, Eq)] pub struct WorktreeRequest { diff --git a/codex-rs/worktree/src/manager.rs b/codex-rs/worktree/src/manager.rs index 1492b4ed514c..21fba7988cbb 100644 --- a/codex-rs/worktree/src/manager.rs +++ b/codex-rs/worktree/src/manager.rs @@ -59,7 +59,7 @@ pub fn ensure_worktree(req: WorktreeRequest) -> Result { } let warnings = dirty::validate_dirty_policy_before_create(&repo.root, req.dirty_policy)?; - let base_ref = req.base_ref.as_deref().unwrap_or("HEAD"); + let has_head = git::status(&repo.root, &["rev-parse", "--verify", "HEAD"]).is_ok(); fs::create_dir_all( worktree_git_root .parent() @@ -75,7 +75,20 @@ pub fn ensure_worktree(req: WorktreeRequest) -> Result { &branch, ], )?; + } else if req.base_ref.is_none() && !has_head { + git::status( + &repo.root, + &[ + "worktree", + "add", + "--orphan", + "-b", + &branch, + &worktree_git_root.to_string_lossy(), + ], + )?; } else { + let base_ref = req.base_ref.as_deref().unwrap_or("HEAD"); git::status( &repo.root, &[ diff --git a/codex-rs/worktree/tests/git_backend.rs b/codex-rs/worktree/tests/git_backend.rs index 637fa0c93994..fbfddb6955b0 100644 --- a/codex-rs/worktree/tests/git_backend.rs +++ b/codex-rs/worktree/tests/git_backend.rs @@ -206,6 +206,102 @@ fn move_all_transfers_dirty_state_and_cleans_source_checkout() -> anyhow::Result Ok(()) } +#[test] +fn move_tracked_transfers_tracked_state_and_leaves_untracked_files() -> anyhow::Result<()> { + let fixture = GitFixture::new()?; + fs::write(fixture.repo.path().join("staged.txt"), "staged changed\n")?; + run_git(fixture.repo.path(), &["add", "staged.txt"])?; + fs::write( + fixture.repo.path().join("unstaged.txt"), + "unstaged changed\n", + )?; + fs::write(fixture.repo.path().join("untracked.txt"), "untracked\n")?; + + let resolution = codex_worktree::ensure_worktree(WorktreeRequest { + codex_home: fixture.codex_home.path().to_path_buf(), + source_cwd: fixture.repo.path().to_path_buf(), + branch: "move-tracked".to_string(), + base_ref: /*base_ref*/ None, + dirty_policy: DirtyPolicy::MoveTracked, + })?; + + assert_eq!( + git( + &resolution.info.worktree_git_root, + &["diff", "--cached", "--name-only"] + )?, + "staged.txt" + ); + assert_eq!( + git(&resolution.info.worktree_git_root, &["diff", "--name-only"])?, + "unstaged.txt" + ); + assert!( + !resolution + .info + .worktree_git_root + .join("untracked.txt") + .exists() + ); + assert_eq!( + git(fixture.repo.path(), &["status", "--short"])?, + "?? untracked.txt" + ); + Ok(()) +} + +#[test] +fn creates_orphan_worktree_from_unborn_repo_without_base_ref() -> anyhow::Result<()> { + let fixture = GitFixture::new_unborn()?; + fs::write(fixture.repo.path().join("README.md"), "hello\n")?; + + let resolution = codex_worktree::ensure_worktree(WorktreeRequest { + codex_home: fixture.codex_home.path().to_path_buf(), + source_cwd: fixture.repo.path().to_path_buf(), + branch: "feature".to_string(), + base_ref: /*base_ref*/ None, + dirty_policy: DirtyPolicy::CopyAll, + })?; + + assert_eq!( + git( + &resolution.info.worktree_git_root, + &["status", "--short", "--branch"] + )?, + "## No commits yet on feature\n?? README.md" + ); + Ok(()) +} + +#[test] +fn move_all_cleans_unborn_source_checkout() -> anyhow::Result<()> { + let fixture = GitFixture::new_unborn()?; + fs::write(fixture.repo.path().join("staged.txt"), "staged\n")?; + run_git(fixture.repo.path(), &["add", "staged.txt"])?; + fs::write(fixture.repo.path().join("untracked.txt"), "untracked\n")?; + + let resolution = codex_worktree::ensure_worktree(WorktreeRequest { + codex_home: fixture.codex_home.path().to_path_buf(), + source_cwd: fixture.repo.path().to_path_buf(), + branch: "feature".to_string(), + base_ref: /*base_ref*/ None, + dirty_policy: DirtyPolicy::MoveAll, + })?; + + assert_eq!( + git( + &resolution.info.worktree_git_root, + &["status", "--short", "--branch"] + )?, + "## No commits yet on feature\nA staged.txt\n?? untracked.txt" + ); + assert_eq!( + git(fixture.repo.path(), &["status", "--short", "--branch"])?, + "## No commits yet on main" + ); + Ok(()) +} + #[test] fn refuses_sibling_path_collision_for_different_branch() -> anyhow::Result<()> { let fixture = GitFixture::new()?; @@ -292,6 +388,15 @@ impl GitFixture { run_git(repo.path(), &["commit", "-m", "initial"])?; Ok(Self { repo, codex_home }) } + + fn new_unborn() -> anyhow::Result { + let repo = TempDir::new()?; + let codex_home = TempDir::new()?; + run_git(repo.path(), &["init", "-b", "main"])?; + run_git(repo.path(), &["config", "user.email", "codex@example.com"])?; + run_git(repo.path(), &["config", "user.name", "Codex"])?; + Ok(Self { repo, codex_home }) + } } fn run_git(cwd: &Path, args: &[&str]) -> anyhow::Result<()> { From 5dc9cf690796497228fb3c1d63b7ee03c9248365 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Sat, 9 May 2026 16:30:58 -0300 Subject: [PATCH 09/12] fix(worktree): preserve remote dirty transfers --- codex-rs/tui/src/remote_worktree.rs | 222 ++++++++++++++++++++++--- codex-rs/worktree/src/dirty.rs | 61 +++++-- codex-rs/worktree/src/manager.rs | 48 +++++- codex-rs/worktree/tests/git_backend.rs | 92 ++++++++++ 4 files changed, 381 insertions(+), 42 deletions(-) diff --git a/codex-rs/tui/src/remote_worktree.rs b/codex-rs/tui/src/remote_worktree.rs index fbf3331e4087..0d537799b5c1 100644 --- a/codex-rs/tui/src/remote_worktree.rs +++ b/codex-rs/tui/src/remote_worktree.rs @@ -187,14 +187,30 @@ pub(crate) async fn ensure_worktree( .await?; } - apply_dirty_policy_after_create( + let prepared_transfer = match prepare_dirty_policy_after_create( runner, request_handle, &repo.root, &worktree_git_root, req.dirty_policy, ) - .await?; + .await + { + Ok(prepared_transfer) => prepared_transfer, + Err(err) => { + return Err(rollback_failed_create( + runner, + &repo.root, + &worktree_git_root, + &branch, + /*delete_branch*/ !branch_exists, + err, + ) + .await); + } + }; + finalize_dirty_policy_after_create(runner, request_handle, &repo.root, prepared_transfer) + .await?; let dirty = dirty_state(runner, &worktree_git_root).await?; let head = git_stdout_result(runner, &worktree_git_root, &["rev-parse", "HEAD"]) .await? @@ -473,57 +489,125 @@ async fn validate_dirty_policy_before_create( } } -async fn apply_dirty_policy_after_create( +async fn prepare_dirty_policy_after_create( runner: &WorkspaceCommandRunner, request_handle: &AppServerRequestHandle, source_root: &Path, worktree_root: &Path, policy: DirtyPolicy, -) -> Result<()> { +) -> Result { let state = dirty_state(runner, source_root).await?; if !state.is_dirty() { - return Ok(()); + return Ok(PreparedRemoteDirtyTransfer { move_plan: None }); } let plan = RemoteTransferPlan::capture(runner, source_root).await?; match policy { - DirtyPolicy::Fail | DirtyPolicy::Ignore => Ok(()), + DirtyPolicy::Fail | DirtyPolicy::Ignore => { + Ok(PreparedRemoteDirtyTransfer { move_plan: None }) + } DirtyPolicy::CopyTracked => { plan.apply_tracked_diff(runner, request_handle, worktree_root) - .await + .await?; + Ok(PreparedRemoteDirtyTransfer { move_plan: None }) } DirtyPolicy::CopyAll => { plan.apply_tracked_diff(runner, request_handle, worktree_root) .await?; plan.copy_untracked(request_handle, source_root, worktree_root) - .await + .await?; + Ok(PreparedRemoteDirtyTransfer { move_plan: None }) } DirtyPolicy::MoveTracked => { plan.apply_tracked_diff(runner, request_handle, worktree_root) .await?; - plan.clean_source_after_move( - runner, - request_handle, - source_root, - /*move_untracked*/ false, - ) - .await + Ok(PreparedRemoteDirtyTransfer { + move_plan: Some(RemoteMovePlan { + transfer: plan, + move_untracked: false, + }), + }) } DirtyPolicy::MoveAll => { plan.apply_tracked_diff(runner, request_handle, worktree_root) .await?; plan.copy_untracked(request_handle, source_root, worktree_root) .await?; - plan.clean_source_after_move( - runner, - request_handle, - source_root, - /*move_untracked*/ true, - ) - .await + Ok(PreparedRemoteDirtyTransfer { + move_plan: Some(RemoteMovePlan { + transfer: plan, + move_untracked: true, + }), + }) } } } +async fn finalize_dirty_policy_after_create( + runner: &WorkspaceCommandRunner, + request_handle: &AppServerRequestHandle, + source_root: &Path, + prepared: PreparedRemoteDirtyTransfer, +) -> Result<()> { + let Some(move_plan) = prepared.move_plan else { + return Ok(()); + }; + move_plan + .transfer + .clean_source_after_move( + runner, + request_handle, + source_root, + move_plan.move_untracked, + ) + .await + .with_context(|| { + "worktree already contains transferred changes, but failed to clean the source checkout after move" + }) +} + +async fn rollback_failed_create( + runner: &WorkspaceCommandRunner, + repo_root: &Path, + worktree_git_root: &Path, + branch: &str, + delete_branch: bool, + err: anyhow::Error, +) -> anyhow::Error { + let mut rollback_errors = Vec::new(); + let worktree_arg = worktree_git_root.to_string_lossy().to_string(); + if let Err(rollback_err) = git_status( + runner, + repo_root, + &["worktree", "remove", "--force", &worktree_arg], + ) + .await + { + rollback_errors.push(rollback_err.to_string()); + } + if delete_branch + && let Err(rollback_err) = git_status(runner, repo_root, &["branch", "-D", branch]).await + { + rollback_errors.push(rollback_err.to_string()); + } + if rollback_errors.is_empty() { + err + } else { + anyhow::anyhow!( + "{err}; additionally failed to roll back newly-created worktree: {}", + rollback_errors.join("; ") + ) + } +} + +struct PreparedRemoteDirtyTransfer { + move_plan: Option, +} + +struct RemoteMovePlan { + transfer: RemoteTransferPlan, + move_untracked: bool, +} + struct RemoteTransferPlan { staged_diff: String, unstaged_diff: String, @@ -534,8 +618,14 @@ struct RemoteTransferPlan { impl RemoteTransferPlan { async fn capture(runner: &WorkspaceCommandRunner, source_root: &Path) -> Result { Ok(Self { - staged_diff: git_stdout(runner, source_root, &["diff", "--cached", "--binary"]).await?, - unstaged_diff: git_stdout(runner, source_root, &["diff", "--binary"]).await?, + staged_diff: git_stdout_raw_uncapped( + runner, + source_root, + &["diff", "--cached", "--binary"], + ) + .await?, + unstaged_diff: git_stdout_raw_uncapped(runner, source_root, &["diff", "--binary"]) + .await?, tracked_paths: tracked_paths(runner, source_root).await?, untracked_paths: untracked_paths(runner, source_root).await?, }) @@ -750,6 +840,23 @@ async fn git_stdout(runner: &WorkspaceCommandRunner, cwd: &Path, args: &[&str]) Ok(output.stdout.trim_end().to_string()) } +async fn git_stdout_raw_uncapped( + runner: &WorkspaceCommandRunner, + cwd: &Path, + args: &[&str], +) -> Result { + let argv = std::iter::once("git") + .chain(args.iter().copied()) + .collect::>(); + let output = runner + .run(WorkspaceCommand::new(argv).cwd(cwd).disable_output_cap()) + .await?; + if !output.success() { + anyhow::bail!("git {} failed: {}", args.join(" "), output.stderr.trim()); + } + Ok(output.stdout) +} + async fn git_stdout_result( runner: &WorkspaceCommandRunner, cwd: &Path, @@ -866,3 +973,70 @@ async fn fs_remove(request_handle: &AppServerRequestHandle, path: &Path) -> Resu .await?; Ok(()) } + +#[cfg(test)] +mod tests { + use std::future::Future; + use std::path::PathBuf; + use std::pin::Pin; + use std::sync::Arc; + use std::sync::Mutex; + + use pretty_assertions::assert_eq; + + use super::git_stdout_raw_uncapped; + use crate::workspace_command::WorkspaceCommand; + use crate::workspace_command::WorkspaceCommandError; + use crate::workspace_command::WorkspaceCommandExecutor; + use crate::workspace_command::WorkspaceCommandOutput; + use crate::workspace_command::WorkspaceCommandRunner; + + struct FakeRunner { + seen: Mutex>, + output: WorkspaceCommandOutput, + } + + impl WorkspaceCommandExecutor for FakeRunner { + fn run( + &self, + command: WorkspaceCommand, + ) -> Pin< + Box< + dyn Future> + + Send + + '_, + >, + > { + self.seen.lock().expect("seen lock").push(command); + Box::pin(async move { Ok(self.output.clone()) }) + } + } + + #[tokio::test] + async fn raw_git_stdout_preserves_patch_newline_and_disables_output_cap() { + let fake_runner = Arc::new(FakeRunner { + seen: Mutex::new(Vec::new()), + output: WorkspaceCommandOutput { + exit_code: 0, + stdout: "diff --git a/file b/file\n".to_string(), + stderr: String::new(), + }, + }); + let runner: WorkspaceCommandRunner = fake_runner.clone(); + + let output = git_stdout_raw_uncapped( + &runner, + &PathBuf::from("/repo"), + &["diff", "--cached", "--binary"], + ) + .await + .expect("raw git stdout"); + + assert_eq!(output, "diff --git a/file b/file\n"); + let seen = fake_runner.seen.lock().expect("seen lock"); + assert_eq!(seen.len(), 1); + assert_eq!(seen[0].argv, vec!["git", "diff", "--cached", "--binary"]); + assert_eq!(seen[0].cwd, Some(PathBuf::from("/repo"))); + assert!(seen[0].disable_output_cap); + } +} diff --git a/codex-rs/worktree/src/dirty.rs b/codex-rs/worktree/src/dirty.rs index 8fd3cce88693..75e1196b34cb 100644 --- a/codex-rs/worktree/src/dirty.rs +++ b/codex-rs/worktree/src/dirty.rs @@ -28,6 +28,17 @@ struct TransferPlan { untracked_paths: Vec, } +#[derive(Debug)] +pub(crate) struct PreparedDirtyTransfer { + move_plan: Option, +} + +#[derive(Debug)] +struct MovePlan { + transfer: TransferPlan, + move_untracked: bool, +} + #[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct DirtyState { @@ -82,50 +93,68 @@ pub fn validate_dirty_policy_before_create( } } -pub fn apply_dirty_policy_after_create( +pub(crate) fn prepare_dirty_policy_after_create( source_root: &Path, worktree_root: &Path, policy: DirtyPolicy, -) -> Result<()> { +) -> Result { let state = dirty_state(source_root)?; if !state.is_dirty() { - return Ok(()); + return Ok(PreparedDirtyTransfer { move_plan: None }); } match policy { - DirtyPolicy::Fail | DirtyPolicy::Ignore => Ok(()), + DirtyPolicy::Fail | DirtyPolicy::Ignore => Ok(PreparedDirtyTransfer { move_plan: None }), DirtyPolicy::CopyTracked => { let plan = TransferPlan::capture(source_root)?; - plan.apply_tracked_diff(worktree_root) + plan.apply_tracked_diff(worktree_root)?; + Ok(PreparedDirtyTransfer { move_plan: None }) } DirtyPolicy::CopyAll => { let plan = TransferPlan::capture(source_root)?; plan.apply_tracked_diff(worktree_root)?; copy_untracked_files_at_paths(source_root, worktree_root, &plan.untracked_paths)?; - Ok(()) + Ok(PreparedDirtyTransfer { move_plan: None }) } DirtyPolicy::MoveTracked => { let plan = TransferPlan::capture(source_root)?; plan.apply_tracked_diff(worktree_root)?; - plan.clean_source_after_move(source_root, /*move_untracked*/ false) - .with_context(|| { - "worktree already contains transferred changes, but failed to clean the source checkout after move" - })?; - Ok(()) + Ok(PreparedDirtyTransfer { + move_plan: Some(MovePlan { + transfer: plan, + move_untracked: false, + }), + }) } DirtyPolicy::MoveAll => { let plan = TransferPlan::capture(source_root)?; plan.apply_tracked_diff(worktree_root)?; copy_untracked_files_at_paths(source_root, worktree_root, &plan.untracked_paths)?; - plan.clean_source_after_move(source_root, /*move_untracked*/ true) - .with_context(|| { - "worktree already contains transferred changes, but failed to clean the source checkout after move" - })?; - Ok(()) + Ok(PreparedDirtyTransfer { + move_plan: Some(MovePlan { + transfer: plan, + move_untracked: true, + }), + }) } } } +pub(crate) fn finalize_dirty_policy_after_create( + source_root: &Path, + prepared: PreparedDirtyTransfer, +) -> Result<()> { + let Some(move_plan) = prepared.move_plan else { + return Ok(()); + }; + move_plan + .transfer + .clean_source_after_move(source_root, move_plan.move_untracked) + .with_context(|| { + "worktree already contains transferred changes, but failed to clean the source checkout after move" + }) +} + fn bail_for_dirty_source() -> Result { anyhow::bail!( "source checkout has uncommitted changes; use --worktree-dirty ignore, copy-tracked, copy-all, move-tracked, or move-all" diff --git a/codex-rs/worktree/src/manager.rs b/codex-rs/worktree/src/manager.rs index 21fba7988cbb..231bf55e14dc 100644 --- a/codex-rs/worktree/src/manager.rs +++ b/codex-rs/worktree/src/manager.rs @@ -59,13 +59,14 @@ pub fn ensure_worktree(req: WorktreeRequest) -> Result { } let warnings = dirty::validate_dirty_policy_before_create(&repo.root, req.dirty_policy)?; + let branch_exists = branch_exists(&repo.root, &branch); let has_head = git::status(&repo.root, &["rev-parse", "--verify", "HEAD"]).is_ok(); fs::create_dir_all( worktree_git_root .parent() .context("managed worktree path has no parent")?, )?; - if branch_exists(&repo.root, &branch) { + if branch_exists { git::status( &repo.root, &[ @@ -102,7 +103,23 @@ pub fn ensure_worktree(req: WorktreeRequest) -> Result { )?; } - dirty::apply_dirty_policy_after_create(&repo.root, &worktree_git_root, req.dirty_policy)?; + let prepared_transfer = match dirty::prepare_dirty_policy_after_create( + &repo.root, + &worktree_git_root, + req.dirty_policy, + ) { + Ok(prepared_transfer) => prepared_transfer, + Err(err) => { + return Err(rollback_failed_create( + &repo.root, + &worktree_git_root, + &branch, + /*delete_branch*/ !branch_exists, + err, + )); + } + }; + dirty::finalize_dirty_policy_after_create(&repo.root, prepared_transfer)?; let dirty = dirty::dirty_state(&worktree_git_root)?; let head = git::stdout(&worktree_git_root, &["rev-parse", "HEAD"]).ok(); let mut info = WorktreeInfo { @@ -137,6 +154,33 @@ pub fn ensure_worktree(req: WorktreeRequest) -> Result { }) } +fn rollback_failed_create( + repo_root: &Path, + worktree_git_root: &Path, + branch: &str, + delete_branch: bool, + err: anyhow::Error, +) -> anyhow::Error { + let mut rollback_errors = Vec::new(); + let worktree_arg = worktree_git_root.to_string_lossy(); + if let Err(rollback_err) = + git::status(repo_root, &["worktree", "remove", "--force", &worktree_arg]) + { + rollback_errors.push(rollback_err.to_string()); + } + if delete_branch && let Err(rollback_err) = git::status(repo_root, &["branch", "-D", branch]) { + rollback_errors.push(rollback_err.to_string()); + } + if rollback_errors.is_empty() { + err + } else { + anyhow::anyhow!( + "{err}; additionally failed to roll back newly-created worktree: {}", + rollback_errors.join("; ") + ) + } +} + pub fn resolve_worktree(codex_home: &Path, cwd: &Path) -> Result> { let Ok(root) = git::stdout(cwd, &["rev-parse", "--show-toplevel"]) else { return Ok(None); diff --git a/codex-rs/worktree/tests/git_backend.rs b/codex-rs/worktree/tests/git_backend.rs index fbfddb6955b0..1497e7ac9407 100644 --- a/codex-rs/worktree/tests/git_backend.rs +++ b/codex-rs/worktree/tests/git_backend.rs @@ -250,6 +250,72 @@ fn move_tracked_transfers_tracked_state_and_leaves_untracked_files() -> anyhow:: Ok(()) } +#[test] +fn failed_transfer_rolls_back_new_worktree_and_branch() -> anyhow::Result<()> { + let fixture = GitFixture::new_with_base_before_tracked_file()?; + fs::write(fixture.repo.path().join("tracked.txt"), "changed\n")?; + + let err = codex_worktree::ensure_worktree(WorktreeRequest { + codex_home: fixture.codex_home.path().to_path_buf(), + source_cwd: fixture.repo.path().to_path_buf(), + branch: "rollback-created".to_string(), + base_ref: Some("HEAD~1".to_string()), + dirty_policy: DirtyPolicy::CopyTracked, + }) + .expect_err("tracked patch should not apply against an older base"); + + assert!( + err.to_string() + .contains("failed to apply unstaged changes to worktree"), + "{err:#}" + ); + assert!( + !fixture + .repo + .path() + .with_file_name(format!( + "{}.rollback-created", + fixture.repo.path().file_name().unwrap().to_string_lossy() + )) + .exists() + ); + assert!(!branch_exists(fixture.repo.path(), "rollback-created")?); + assert_eq!( + git(fixture.repo.path(), &["status", "--short"])?, + " M tracked.txt" + ); + Ok(()) +} + +#[test] +fn failed_transfer_preserves_preexisting_branch() -> anyhow::Result<()> { + let fixture = GitFixture::new_with_base_before_tracked_file()?; + run_git(fixture.repo.path(), &["branch", "keep-me", "HEAD~1"])?; + fs::write(fixture.repo.path().join("tracked.txt"), "changed\n")?; + + codex_worktree::ensure_worktree(WorktreeRequest { + codex_home: fixture.codex_home.path().to_path_buf(), + source_cwd: fixture.repo.path().to_path_buf(), + branch: "keep-me".to_string(), + base_ref: /*base_ref*/ None, + dirty_policy: DirtyPolicy::CopyTracked, + }) + .expect_err("tracked patch should not apply against the preexisting branch"); + + assert!(branch_exists(fixture.repo.path(), "keep-me")?); + assert!( + !fixture + .repo + .path() + .with_file_name(format!( + "{}.keep-me", + fixture.repo.path().file_name().unwrap().to_string_lossy() + )) + .exists() + ); + Ok(()) +} + #[test] fn creates_orphan_worktree_from_unborn_repo_without_base_ref() -> anyhow::Result<()> { let fixture = GitFixture::new_unborn()?; @@ -397,6 +463,19 @@ impl GitFixture { run_git(repo.path(), &["config", "user.name", "Codex"])?; Ok(Self { repo, codex_home }) } + + fn new_with_base_before_tracked_file() -> anyhow::Result { + let repo = TempDir::new()?; + let codex_home = TempDir::new()?; + run_git(repo.path(), &["init", "-b", "main"])?; + run_git(repo.path(), &["config", "user.email", "codex@example.com"])?; + run_git(repo.path(), &["config", "user.name", "Codex"])?; + run_git(repo.path(), &["commit", "--allow-empty", "-m", "base"])?; + fs::write(repo.path().join("tracked.txt"), "original\n")?; + run_git(repo.path(), &["add", "tracked.txt"])?; + run_git(repo.path(), &["commit", "-m", "add tracked file"])?; + Ok(Self { repo, codex_home }) + } } fn run_git(cwd: &Path, args: &[&str]) -> anyhow::Result<()> { @@ -422,3 +501,16 @@ fn git(cwd: &Path, args: &[&str]) -> anyhow::Result { } Ok(String::from_utf8(output.stdout)?.trim_end().to_string()) } + +fn branch_exists(cwd: &Path, branch: &str) -> anyhow::Result { + let output = Command::new("git") + .args([ + "show-ref", + "--verify", + "--quiet", + &format!("refs/heads/{branch}"), + ]) + .current_dir(cwd) + .output()?; + Ok(output.status.success()) +} From c0a679534753eadf0d3dd8d5f2da765c20c8ab96 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Sat, 9 May 2026 21:36:06 -0300 Subject: [PATCH 10/12] refactor(worktree): route interactive flow through app-server --- codex-rs/Cargo.lock | 1 + .../schema/json/ClientRequest.json | 187 +++ .../codex_app_server_protocol.schemas.json | 396 +++++++ .../codex_app_server_protocol.v2.schemas.json | 396 +++++++ .../schema/json/v2/WorktreeCreateParams.json | 44 + .../json/v2/WorktreeCreateResponse.json | 152 +++ .../json/v2/WorktreeInspectSourceParams.json | 15 + .../v2/WorktreeInspectSourceResponse.json | 35 + .../schema/json/v2/WorktreeListParams.json | 15 + .../schema/json/v2/WorktreeListResponse.json | 133 +++ .../schema/json/v2/WorktreeRemoveParams.json | 27 + .../json/v2/WorktreeRemoveResponse.json | 20 + .../schema/typescript/ClientRequest.ts | 6 +- .../typescript/v2/WorktreeCreateParams.ts | 13 + .../typescript/v2/WorktreeCreateResponse.ts | 10 + .../typescript/v2/WorktreeDirtyPolicy.ts | 5 + .../typescript/v2/WorktreeDirtyState.ts | 5 + .../schema/typescript/v2/WorktreeInfo.ts | 11 + .../v2/WorktreeInspectSourceParams.ts | 12 + .../v2/WorktreeInspectSourceResponse.ts | 9 + .../typescript/v2/WorktreeListParams.ts | 12 + .../typescript/v2/WorktreeListResponse.ts | 9 + .../schema/typescript/v2/WorktreeLocation.ts | 5 + .../typescript/v2/WorktreeRemoveParams.ts | 12 + .../typescript/v2/WorktreeRemoveResponse.ts | 8 + .../schema/typescript/v2/WorktreeSource.ts | 5 + .../schema/typescript/v2/WorktreeWarning.ts | 5 + .../schema/typescript/v2/index.ts | 14 + .../src/protocol/common.rs | 20 + .../src/protocol/v2/mod.rs | 2 + .../src/protocol/v2/worktree.rs | 158 +++ codex-rs/app-server/Cargo.toml | 1 + codex-rs/app-server/README.md | 7 + codex-rs/app-server/src/message_processor.rs | 26 +- codex-rs/app-server/src/request_processors.rs | 16 + .../request_processors/thread_processor.rs | 34 + .../request_processors/worktree_processor.rs | 201 ++++ codex-rs/cli/src/main.rs | 29 - codex-rs/tui/src/app/event_dispatch.rs | 4 - codex-rs/tui/src/app/worktree.rs | 286 +++-- codex-rs/tui/src/app_event.rs | 6 - codex-rs/tui/src/app_server_session.rs | 18 - codex-rs/tui/src/lib.rs | 93 +- codex-rs/tui/src/remote_worktree.rs | 1042 ----------------- 44 files changed, 2279 insertions(+), 1226 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/json/v2/WorktreeCreateParams.json create mode 100644 codex-rs/app-server-protocol/schema/json/v2/WorktreeCreateResponse.json create mode 100644 codex-rs/app-server-protocol/schema/json/v2/WorktreeInspectSourceParams.json create mode 100644 codex-rs/app-server-protocol/schema/json/v2/WorktreeInspectSourceResponse.json create mode 100644 codex-rs/app-server-protocol/schema/json/v2/WorktreeListParams.json create mode 100644 codex-rs/app-server-protocol/schema/json/v2/WorktreeListResponse.json create mode 100644 codex-rs/app-server-protocol/schema/json/v2/WorktreeRemoveParams.json create mode 100644 codex-rs/app-server-protocol/schema/json/v2/WorktreeRemoveResponse.json create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/WorktreeCreateParams.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/WorktreeCreateResponse.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/WorktreeDirtyPolicy.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/WorktreeDirtyState.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/WorktreeInfo.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/WorktreeInspectSourceParams.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/WorktreeInspectSourceResponse.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/WorktreeListParams.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/WorktreeListResponse.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/WorktreeLocation.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/WorktreeRemoveParams.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/WorktreeRemoveResponse.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/WorktreeSource.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/WorktreeWarning.ts create mode 100644 codex-rs/app-server-protocol/src/protocol/v2/worktree.rs create mode 100644 codex-rs/app-server/src/request_processors/worktree_processor.rs delete mode 100644 codex-rs/tui/src/remote_worktree.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index d914890f558e..8c2fa5141c19 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1896,6 +1896,7 @@ dependencies = [ "codex-utils-cli", "codex-utils-json-to-toml", "codex-utils-pty", + "codex-worktree", "core_test_support", "flate2", "futures", diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 36596807ba04..6a49d3129c64 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -4564,6 +4564,97 @@ "mode" ], "type": "object" + }, + "WorktreeCreateParams": { + "description": "Create or reuse a managed worktree from the repository containing \\`cwd\\`.", + "properties": { + "baseRef": { + "type": [ + "string", + "null" + ] + }, + "branch": { + "type": "string" + }, + "cwd": { + "description": "Repository-relative workspace cwd to use as the source checkout.", + "type": [ + "string", + "null" + ] + }, + "dirtyPolicy": { + "$ref": "#/definitions/WorktreeDirtyPolicy" + } + }, + "required": [ + "branch", + "dirtyPolicy" + ], + "type": "object" + }, + "WorktreeDirtyPolicy": { + "enum": [ + "fail", + "ignore", + "copyTracked", + "copyAll", + "moveTracked", + "moveAll" + ], + "type": "string" + }, + "WorktreeInspectSourceParams": { + "description": "Inspect dirty state for the repository containing \\`cwd\\`.", + "properties": { + "cwd": { + "description": "Repository-relative workspace cwd to inspect. Omitted uses app-server's effective cwd.", + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "WorktreeListParams": { + "description": "Request the managed worktrees associated with the repository containing \\`cwd\\`.", + "properties": { + "cwd": { + "description": "Repository-relative workspace cwd to inspect. Omitted uses app-server's effective cwd.", + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "WorktreeRemoveParams": { + "description": "Remove a managed worktree in the repository containing \\`cwd\\`.", + "properties": { + "cwd": { + "description": "Repository-relative workspace cwd to use when resolving \\`name_or_path\\`.", + "type": [ + "string", + "null" + ] + }, + "deleteBranch": { + "type": "boolean" + }, + "force": { + "type": "boolean" + }, + "nameOrPath": { + "type": "string" + } + }, + "required": [ + "nameOrPath" + ], + "type": "object" } }, "description": "Request from the client to the server.", @@ -5578,6 +5669,102 @@ "title": "Fs/unwatchRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "worktree/list" + ], + "title": "Worktree/listRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/WorktreeListParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Worktree/listRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "worktree/inspectSource" + ], + "title": "Worktree/inspectSourceRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/WorktreeInspectSourceParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Worktree/inspectSourceRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "worktree/create" + ], + "title": "Worktree/createRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/WorktreeCreateParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Worktree/createRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "worktree/remove" + ], + "title": "Worktree/removeRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/WorktreeRemoveParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Worktree/removeRequest", + "type": "object" + }, { "properties": { "id": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index acb4b16d936f..b113e717e7b5 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -1194,6 +1194,102 @@ "title": "Fs/unwatchRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "worktree/list" + ], + "title": "Worktree/listRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/WorktreeListParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Worktree/listRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "worktree/inspectSource" + ], + "title": "Worktree/inspectSourceRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/WorktreeInspectSourceParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Worktree/inspectSourceRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "worktree/create" + ], + "title": "Worktree/createRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/WorktreeCreateParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Worktree/createRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "worktree/remove" + ], + "title": "Worktree/removeRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/WorktreeRemoveParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Worktree/removeRequest", + "type": "object" + }, { "properties": { "id": { @@ -18653,6 +18749,306 @@ "title": "WindowsWorldWritableWarningNotification", "type": "object" }, + "WorktreeCreateParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Create or reuse a managed worktree from the repository containing \\`cwd\\`.", + "properties": { + "baseRef": { + "type": [ + "string", + "null" + ] + }, + "branch": { + "type": "string" + }, + "cwd": { + "description": "Repository-relative workspace cwd to use as the source checkout.", + "type": [ + "string", + "null" + ] + }, + "dirtyPolicy": { + "$ref": "#/definitions/v2/WorktreeDirtyPolicy" + } + }, + "required": [ + "branch", + "dirtyPolicy" + ], + "title": "WorktreeCreateParams", + "type": "object" + }, + "WorktreeCreateResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Result returned by \\`worktree/create\\`.", + "properties": { + "info": { + "$ref": "#/definitions/v2/WorktreeInfo" + }, + "reused": { + "type": "boolean" + }, + "warnings": { + "items": { + "$ref": "#/definitions/v2/WorktreeWarning" + }, + "type": "array" + } + }, + "required": [ + "info", + "reused", + "warnings" + ], + "title": "WorktreeCreateResponse", + "type": "object" + }, + "WorktreeDirtyPolicy": { + "enum": [ + "fail", + "ignore", + "copyTracked", + "copyAll", + "moveTracked", + "moveAll" + ], + "type": "string" + }, + "WorktreeDirtyState": { + "properties": { + "hasStagedChanges": { + "type": "boolean" + }, + "hasUnstagedChanges": { + "type": "boolean" + }, + "hasUntrackedFiles": { + "type": "boolean" + } + }, + "required": [ + "hasStagedChanges", + "hasUnstagedChanges", + "hasUntrackedFiles" + ], + "type": "object" + }, + "WorktreeInfo": { + "description": "Server-native representation of a managed worktree.", + "properties": { + "branch": { + "type": [ + "string", + "null" + ] + }, + "commonGitDir": { + "type": "string" + }, + "dirty": { + "$ref": "#/definitions/v2/WorktreeDirtyState" + }, + "head": { + "type": [ + "string", + "null" + ] + }, + "id": { + "type": "string" + }, + "location": { + "$ref": "#/definitions/v2/WorktreeLocation" + }, + "metadataPath": { + "type": "string" + }, + "name": { + "type": "string" + }, + "originalRelativeCwd": { + "type": "string" + }, + "ownerThreadId": { + "type": [ + "string", + "null" + ] + }, + "repoName": { + "type": "string" + }, + "repoRoot": { + "type": "string" + }, + "slug": { + "type": "string" + }, + "source": { + "$ref": "#/definitions/v2/WorktreeSource" + }, + "workspaceCwd": { + "type": "string" + }, + "worktreeGitRoot": { + "type": "string" + } + }, + "required": [ + "commonGitDir", + "dirty", + "id", + "location", + "metadataPath", + "name", + "originalRelativeCwd", + "repoName", + "repoRoot", + "slug", + "source", + "workspaceCwd", + "worktreeGitRoot" + ], + "type": "object" + }, + "WorktreeInspectSourceParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Inspect dirty state for the repository containing \\`cwd\\`.", + "properties": { + "cwd": { + "description": "Repository-relative workspace cwd to inspect. Omitted uses app-server's effective cwd.", + "type": [ + "string", + "null" + ] + } + }, + "title": "WorktreeInspectSourceParams", + "type": "object" + }, + "WorktreeInspectSourceResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Dirty-state response returned by \\`worktree/inspectSource\\`.", + "properties": { + "dirty": { + "$ref": "#/definitions/v2/WorktreeDirtyState" + } + }, + "required": [ + "dirty" + ], + "title": "WorktreeInspectSourceResponse", + "type": "object" + }, + "WorktreeListParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Request the managed worktrees associated with the repository containing \\`cwd\\`.", + "properties": { + "cwd": { + "description": "Repository-relative workspace cwd to inspect. Omitted uses app-server's effective cwd.", + "type": [ + "string", + "null" + ] + } + }, + "title": "WorktreeListParams", + "type": "object" + }, + "WorktreeListResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Managed worktrees returned by \\`worktree/list\\`.", + "properties": { + "data": { + "items": { + "$ref": "#/definitions/v2/WorktreeInfo" + }, + "type": "array" + } + }, + "required": [ + "data" + ], + "title": "WorktreeListResponse", + "type": "object" + }, + "WorktreeLocation": { + "enum": [ + "sibling", + "codexHome", + "external" + ], + "type": "string" + }, + "WorktreeRemoveParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Remove a managed worktree in the repository containing \\`cwd\\`.", + "properties": { + "cwd": { + "description": "Repository-relative workspace cwd to use when resolving \\`name_or_path\\`.", + "type": [ + "string", + "null" + ] + }, + "deleteBranch": { + "type": "boolean" + }, + "force": { + "type": "boolean" + }, + "nameOrPath": { + "type": "string" + } + }, + "required": [ + "nameOrPath" + ], + "title": "WorktreeRemoveParams", + "type": "object" + }, + "WorktreeRemoveResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Result returned by \\`worktree/remove\\`.", + "properties": { + "deletedBranch": { + "type": [ + "string", + "null" + ] + }, + "removedPath": { + "type": "string" + } + }, + "required": [ + "removedPath" + ], + "title": "WorktreeRemoveResponse", + "type": "object" + }, + "WorktreeSource": { + "enum": [ + "cli", + "app", + "legacy", + "git" + ], + "type": "string" + }, + "WorktreeWarning": { + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "type": "object" + }, "WriteStatus": { "enum": [ "ok", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index ded3dc2b8717..84fe8f04f885 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -1953,6 +1953,102 @@ "title": "Fs/unwatchRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "worktree/list" + ], + "title": "Worktree/listRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/WorktreeListParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Worktree/listRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "worktree/inspectSource" + ], + "title": "Worktree/inspectSourceRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/WorktreeInspectSourceParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Worktree/inspectSourceRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "worktree/create" + ], + "title": "Worktree/createRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/WorktreeCreateParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Worktree/createRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "worktree/remove" + ], + "title": "Worktree/removeRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/WorktreeRemoveParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Worktree/removeRequest", + "type": "object" + }, { "properties": { "id": { @@ -16539,6 +16635,306 @@ "title": "WindowsWorldWritableWarningNotification", "type": "object" }, + "WorktreeCreateParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Create or reuse a managed worktree from the repository containing \\`cwd\\`.", + "properties": { + "baseRef": { + "type": [ + "string", + "null" + ] + }, + "branch": { + "type": "string" + }, + "cwd": { + "description": "Repository-relative workspace cwd to use as the source checkout.", + "type": [ + "string", + "null" + ] + }, + "dirtyPolicy": { + "$ref": "#/definitions/WorktreeDirtyPolicy" + } + }, + "required": [ + "branch", + "dirtyPolicy" + ], + "title": "WorktreeCreateParams", + "type": "object" + }, + "WorktreeCreateResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Result returned by \\`worktree/create\\`.", + "properties": { + "info": { + "$ref": "#/definitions/WorktreeInfo" + }, + "reused": { + "type": "boolean" + }, + "warnings": { + "items": { + "$ref": "#/definitions/WorktreeWarning" + }, + "type": "array" + } + }, + "required": [ + "info", + "reused", + "warnings" + ], + "title": "WorktreeCreateResponse", + "type": "object" + }, + "WorktreeDirtyPolicy": { + "enum": [ + "fail", + "ignore", + "copyTracked", + "copyAll", + "moveTracked", + "moveAll" + ], + "type": "string" + }, + "WorktreeDirtyState": { + "properties": { + "hasStagedChanges": { + "type": "boolean" + }, + "hasUnstagedChanges": { + "type": "boolean" + }, + "hasUntrackedFiles": { + "type": "boolean" + } + }, + "required": [ + "hasStagedChanges", + "hasUnstagedChanges", + "hasUntrackedFiles" + ], + "type": "object" + }, + "WorktreeInfo": { + "description": "Server-native representation of a managed worktree.", + "properties": { + "branch": { + "type": [ + "string", + "null" + ] + }, + "commonGitDir": { + "type": "string" + }, + "dirty": { + "$ref": "#/definitions/WorktreeDirtyState" + }, + "head": { + "type": [ + "string", + "null" + ] + }, + "id": { + "type": "string" + }, + "location": { + "$ref": "#/definitions/WorktreeLocation" + }, + "metadataPath": { + "type": "string" + }, + "name": { + "type": "string" + }, + "originalRelativeCwd": { + "type": "string" + }, + "ownerThreadId": { + "type": [ + "string", + "null" + ] + }, + "repoName": { + "type": "string" + }, + "repoRoot": { + "type": "string" + }, + "slug": { + "type": "string" + }, + "source": { + "$ref": "#/definitions/WorktreeSource" + }, + "workspaceCwd": { + "type": "string" + }, + "worktreeGitRoot": { + "type": "string" + } + }, + "required": [ + "commonGitDir", + "dirty", + "id", + "location", + "metadataPath", + "name", + "originalRelativeCwd", + "repoName", + "repoRoot", + "slug", + "source", + "workspaceCwd", + "worktreeGitRoot" + ], + "type": "object" + }, + "WorktreeInspectSourceParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Inspect dirty state for the repository containing \\`cwd\\`.", + "properties": { + "cwd": { + "description": "Repository-relative workspace cwd to inspect. Omitted uses app-server's effective cwd.", + "type": [ + "string", + "null" + ] + } + }, + "title": "WorktreeInspectSourceParams", + "type": "object" + }, + "WorktreeInspectSourceResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Dirty-state response returned by \\`worktree/inspectSource\\`.", + "properties": { + "dirty": { + "$ref": "#/definitions/WorktreeDirtyState" + } + }, + "required": [ + "dirty" + ], + "title": "WorktreeInspectSourceResponse", + "type": "object" + }, + "WorktreeListParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Request the managed worktrees associated with the repository containing \\`cwd\\`.", + "properties": { + "cwd": { + "description": "Repository-relative workspace cwd to inspect. Omitted uses app-server's effective cwd.", + "type": [ + "string", + "null" + ] + } + }, + "title": "WorktreeListParams", + "type": "object" + }, + "WorktreeListResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Managed worktrees returned by \\`worktree/list\\`.", + "properties": { + "data": { + "items": { + "$ref": "#/definitions/WorktreeInfo" + }, + "type": "array" + } + }, + "required": [ + "data" + ], + "title": "WorktreeListResponse", + "type": "object" + }, + "WorktreeLocation": { + "enum": [ + "sibling", + "codexHome", + "external" + ], + "type": "string" + }, + "WorktreeRemoveParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Remove a managed worktree in the repository containing \\`cwd\\`.", + "properties": { + "cwd": { + "description": "Repository-relative workspace cwd to use when resolving \\`name_or_path\\`.", + "type": [ + "string", + "null" + ] + }, + "deleteBranch": { + "type": "boolean" + }, + "force": { + "type": "boolean" + }, + "nameOrPath": { + "type": "string" + } + }, + "required": [ + "nameOrPath" + ], + "title": "WorktreeRemoveParams", + "type": "object" + }, + "WorktreeRemoveResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Result returned by \\`worktree/remove\\`.", + "properties": { + "deletedBranch": { + "type": [ + "string", + "null" + ] + }, + "removedPath": { + "type": "string" + } + }, + "required": [ + "removedPath" + ], + "title": "WorktreeRemoveResponse", + "type": "object" + }, + "WorktreeSource": { + "enum": [ + "cli", + "app", + "legacy", + "git" + ], + "type": "string" + }, + "WorktreeWarning": { + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "type": "object" + }, "WriteStatus": { "enum": [ "ok", diff --git a/codex-rs/app-server-protocol/schema/json/v2/WorktreeCreateParams.json b/codex-rs/app-server-protocol/schema/json/v2/WorktreeCreateParams.json new file mode 100644 index 000000000000..2dd71ba61b9d --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/WorktreeCreateParams.json @@ -0,0 +1,44 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "WorktreeDirtyPolicy": { + "enum": [ + "fail", + "ignore", + "copyTracked", + "copyAll", + "moveTracked", + "moveAll" + ], + "type": "string" + } + }, + "description": "Create or reuse a managed worktree from the repository containing \\`cwd\\`.", + "properties": { + "baseRef": { + "type": [ + "string", + "null" + ] + }, + "branch": { + "type": "string" + }, + "cwd": { + "description": "Repository-relative workspace cwd to use as the source checkout.", + "type": [ + "string", + "null" + ] + }, + "dirtyPolicy": { + "$ref": "#/definitions/WorktreeDirtyPolicy" + } + }, + "required": [ + "branch", + "dirtyPolicy" + ], + "title": "WorktreeCreateParams", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/WorktreeCreateResponse.json b/codex-rs/app-server-protocol/schema/json/v2/WorktreeCreateResponse.json new file mode 100644 index 000000000000..dc04b8b8bd13 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/WorktreeCreateResponse.json @@ -0,0 +1,152 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "WorktreeDirtyState": { + "properties": { + "hasStagedChanges": { + "type": "boolean" + }, + "hasUnstagedChanges": { + "type": "boolean" + }, + "hasUntrackedFiles": { + "type": "boolean" + } + }, + "required": [ + "hasStagedChanges", + "hasUnstagedChanges", + "hasUntrackedFiles" + ], + "type": "object" + }, + "WorktreeInfo": { + "description": "Server-native representation of a managed worktree.", + "properties": { + "branch": { + "type": [ + "string", + "null" + ] + }, + "commonGitDir": { + "type": "string" + }, + "dirty": { + "$ref": "#/definitions/WorktreeDirtyState" + }, + "head": { + "type": [ + "string", + "null" + ] + }, + "id": { + "type": "string" + }, + "location": { + "$ref": "#/definitions/WorktreeLocation" + }, + "metadataPath": { + "type": "string" + }, + "name": { + "type": "string" + }, + "originalRelativeCwd": { + "type": "string" + }, + "ownerThreadId": { + "type": [ + "string", + "null" + ] + }, + "repoName": { + "type": "string" + }, + "repoRoot": { + "type": "string" + }, + "slug": { + "type": "string" + }, + "source": { + "$ref": "#/definitions/WorktreeSource" + }, + "workspaceCwd": { + "type": "string" + }, + "worktreeGitRoot": { + "type": "string" + } + }, + "required": [ + "commonGitDir", + "dirty", + "id", + "location", + "metadataPath", + "name", + "originalRelativeCwd", + "repoName", + "repoRoot", + "slug", + "source", + "workspaceCwd", + "worktreeGitRoot" + ], + "type": "object" + }, + "WorktreeLocation": { + "enum": [ + "sibling", + "codexHome", + "external" + ], + "type": "string" + }, + "WorktreeSource": { + "enum": [ + "cli", + "app", + "legacy", + "git" + ], + "type": "string" + }, + "WorktreeWarning": { + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "type": "object" + } + }, + "description": "Result returned by \\`worktree/create\\`.", + "properties": { + "info": { + "$ref": "#/definitions/WorktreeInfo" + }, + "reused": { + "type": "boolean" + }, + "warnings": { + "items": { + "$ref": "#/definitions/WorktreeWarning" + }, + "type": "array" + } + }, + "required": [ + "info", + "reused", + "warnings" + ], + "title": "WorktreeCreateResponse", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/WorktreeInspectSourceParams.json b/codex-rs/app-server-protocol/schema/json/v2/WorktreeInspectSourceParams.json new file mode 100644 index 000000000000..11c4110a6864 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/WorktreeInspectSourceParams.json @@ -0,0 +1,15 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Inspect dirty state for the repository containing \\`cwd\\`.", + "properties": { + "cwd": { + "description": "Repository-relative workspace cwd to inspect. Omitted uses app-server's effective cwd.", + "type": [ + "string", + "null" + ] + } + }, + "title": "WorktreeInspectSourceParams", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/WorktreeInspectSourceResponse.json b/codex-rs/app-server-protocol/schema/json/v2/WorktreeInspectSourceResponse.json new file mode 100644 index 000000000000..c3674323da5d --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/WorktreeInspectSourceResponse.json @@ -0,0 +1,35 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "WorktreeDirtyState": { + "properties": { + "hasStagedChanges": { + "type": "boolean" + }, + "hasUnstagedChanges": { + "type": "boolean" + }, + "hasUntrackedFiles": { + "type": "boolean" + } + }, + "required": [ + "hasStagedChanges", + "hasUnstagedChanges", + "hasUntrackedFiles" + ], + "type": "object" + } + }, + "description": "Dirty-state response returned by \\`worktree/inspectSource\\`.", + "properties": { + "dirty": { + "$ref": "#/definitions/WorktreeDirtyState" + } + }, + "required": [ + "dirty" + ], + "title": "WorktreeInspectSourceResponse", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/WorktreeListParams.json b/codex-rs/app-server-protocol/schema/json/v2/WorktreeListParams.json new file mode 100644 index 000000000000..50943d6908e0 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/WorktreeListParams.json @@ -0,0 +1,15 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Request the managed worktrees associated with the repository containing \\`cwd\\`.", + "properties": { + "cwd": { + "description": "Repository-relative workspace cwd to inspect. Omitted uses app-server's effective cwd.", + "type": [ + "string", + "null" + ] + } + }, + "title": "WorktreeListParams", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/WorktreeListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/WorktreeListResponse.json new file mode 100644 index 000000000000..5bf4f053db81 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/WorktreeListResponse.json @@ -0,0 +1,133 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "WorktreeDirtyState": { + "properties": { + "hasStagedChanges": { + "type": "boolean" + }, + "hasUnstagedChanges": { + "type": "boolean" + }, + "hasUntrackedFiles": { + "type": "boolean" + } + }, + "required": [ + "hasStagedChanges", + "hasUnstagedChanges", + "hasUntrackedFiles" + ], + "type": "object" + }, + "WorktreeInfo": { + "description": "Server-native representation of a managed worktree.", + "properties": { + "branch": { + "type": [ + "string", + "null" + ] + }, + "commonGitDir": { + "type": "string" + }, + "dirty": { + "$ref": "#/definitions/WorktreeDirtyState" + }, + "head": { + "type": [ + "string", + "null" + ] + }, + "id": { + "type": "string" + }, + "location": { + "$ref": "#/definitions/WorktreeLocation" + }, + "metadataPath": { + "type": "string" + }, + "name": { + "type": "string" + }, + "originalRelativeCwd": { + "type": "string" + }, + "ownerThreadId": { + "type": [ + "string", + "null" + ] + }, + "repoName": { + "type": "string" + }, + "repoRoot": { + "type": "string" + }, + "slug": { + "type": "string" + }, + "source": { + "$ref": "#/definitions/WorktreeSource" + }, + "workspaceCwd": { + "type": "string" + }, + "worktreeGitRoot": { + "type": "string" + } + }, + "required": [ + "commonGitDir", + "dirty", + "id", + "location", + "metadataPath", + "name", + "originalRelativeCwd", + "repoName", + "repoRoot", + "slug", + "source", + "workspaceCwd", + "worktreeGitRoot" + ], + "type": "object" + }, + "WorktreeLocation": { + "enum": [ + "sibling", + "codexHome", + "external" + ], + "type": "string" + }, + "WorktreeSource": { + "enum": [ + "cli", + "app", + "legacy", + "git" + ], + "type": "string" + } + }, + "description": "Managed worktrees returned by \\`worktree/list\\`.", + "properties": { + "data": { + "items": { + "$ref": "#/definitions/WorktreeInfo" + }, + "type": "array" + } + }, + "required": [ + "data" + ], + "title": "WorktreeListResponse", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/WorktreeRemoveParams.json b/codex-rs/app-server-protocol/schema/json/v2/WorktreeRemoveParams.json new file mode 100644 index 000000000000..3bb43971b9c8 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/WorktreeRemoveParams.json @@ -0,0 +1,27 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Remove a managed worktree in the repository containing \\`cwd\\`.", + "properties": { + "cwd": { + "description": "Repository-relative workspace cwd to use when resolving \\`name_or_path\\`.", + "type": [ + "string", + "null" + ] + }, + "deleteBranch": { + "type": "boolean" + }, + "force": { + "type": "boolean" + }, + "nameOrPath": { + "type": "string" + } + }, + "required": [ + "nameOrPath" + ], + "title": "WorktreeRemoveParams", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/WorktreeRemoveResponse.json b/codex-rs/app-server-protocol/schema/json/v2/WorktreeRemoveResponse.json new file mode 100644 index 000000000000..a71fd4c12789 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/WorktreeRemoveResponse.json @@ -0,0 +1,20 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Result returned by \\`worktree/remove\\`.", + "properties": { + "deletedBranch": { + "type": [ + "string", + "null" + ] + }, + "removedPath": { + "type": "string" + } + }, + "required": [ + "removedPath" + ], + "title": "WorktreeRemoveResponse", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts index 1c03d2eb3715..9f0030d994fb 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts @@ -78,8 +78,12 @@ import type { TurnInterruptParams } from "./v2/TurnInterruptParams"; import type { TurnStartParams } from "./v2/TurnStartParams"; import type { TurnSteerParams } from "./v2/TurnSteerParams"; import type { WindowsSandboxSetupStartParams } from "./v2/WindowsSandboxSetupStartParams"; +import type { WorktreeCreateParams } from "./v2/WorktreeCreateParams"; +import type { WorktreeInspectSourceParams } from "./v2/WorktreeInspectSourceParams"; +import type { WorktreeListParams } from "./v2/WorktreeListParams"; +import type { WorktreeRemoveParams } from "./v2/WorktreeRemoveParams"; /** * Request from the client to the server. */ -export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/approveGuardianDeniedAction", id: RequestId, params: ThreadApproveGuardianDeniedActionParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "thread/inject_items", id: RequestId, params: ThreadInjectItemsParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "hooks/list", id: RequestId, params: HooksListParams, } | { "method": "marketplace/add", id: RequestId, params: MarketplaceAddParams, } | { "method": "marketplace/remove", id: RequestId, params: MarketplaceRemoveParams, } | { "method": "marketplace/upgrade", id: RequestId, params: MarketplaceUpgradeParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "plugin/skill/read", id: RequestId, params: PluginSkillReadParams, } | { "method": "plugin/share/save", id: RequestId, params: PluginShareSaveParams, } | { "method": "plugin/share/updateTargets", id: RequestId, params: PluginShareUpdateTargetsParams, } | { "method": "plugin/share/list", id: RequestId, params: PluginShareListParams, } | { "method": "plugin/share/delete", id: RequestId, params: PluginShareDeleteParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "device/key/create", id: RequestId, params: DeviceKeyCreateParams, } | { "method": "device/key/public", id: RequestId, params: DeviceKeyPublicParams, } | { "method": "device/key/sign", id: RequestId, params: DeviceKeySignParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "fs/watch", id: RequestId, params: FsWatchParams, } | { "method": "fs/unwatch", id: RequestId, params: FsUnwatchParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "modelProvider/capabilities/read", id: RequestId, params: ModelProviderCapabilitiesReadParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "experimentalFeature/enablement/set", id: RequestId, params: ExperimentalFeatureEnablementSetParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "mcpServer/resource/read", id: RequestId, params: McpResourceReadParams, } | { "method": "mcpServer/tool/call", id: RequestId, params: McpServerToolCallParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "windowsSandbox/readiness", id: RequestId, params: undefined, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "account/sendAddCreditsNudgeEmail", id: RequestId, params: SendAddCreditsNudgeEmailParams, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; +export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/approveGuardianDeniedAction", id: RequestId, params: ThreadApproveGuardianDeniedActionParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "thread/inject_items", id: RequestId, params: ThreadInjectItemsParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "hooks/list", id: RequestId, params: HooksListParams, } | { "method": "marketplace/add", id: RequestId, params: MarketplaceAddParams, } | { "method": "marketplace/remove", id: RequestId, params: MarketplaceRemoveParams, } | { "method": "marketplace/upgrade", id: RequestId, params: MarketplaceUpgradeParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "plugin/skill/read", id: RequestId, params: PluginSkillReadParams, } | { "method": "plugin/share/save", id: RequestId, params: PluginShareSaveParams, } | { "method": "plugin/share/updateTargets", id: RequestId, params: PluginShareUpdateTargetsParams, } | { "method": "plugin/share/list", id: RequestId, params: PluginShareListParams, } | { "method": "plugin/share/delete", id: RequestId, params: PluginShareDeleteParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "device/key/create", id: RequestId, params: DeviceKeyCreateParams, } | { "method": "device/key/public", id: RequestId, params: DeviceKeyPublicParams, } | { "method": "device/key/sign", id: RequestId, params: DeviceKeySignParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "fs/watch", id: RequestId, params: FsWatchParams, } | { "method": "fs/unwatch", id: RequestId, params: FsUnwatchParams, } | { "method": "worktree/list", id: RequestId, params: WorktreeListParams, } | { "method": "worktree/inspectSource", id: RequestId, params: WorktreeInspectSourceParams, } | { "method": "worktree/create", id: RequestId, params: WorktreeCreateParams, } | { "method": "worktree/remove", id: RequestId, params: WorktreeRemoveParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "modelProvider/capabilities/read", id: RequestId, params: ModelProviderCapabilitiesReadParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "experimentalFeature/enablement/set", id: RequestId, params: ExperimentalFeatureEnablementSetParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "mcpServer/resource/read", id: RequestId, params: McpResourceReadParams, } | { "method": "mcpServer/tool/call", id: RequestId, params: McpServerToolCallParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "windowsSandbox/readiness", id: RequestId, params: undefined, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "account/sendAddCreditsNudgeEmail", id: RequestId, params: SendAddCreditsNudgeEmailParams, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/WorktreeCreateParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/WorktreeCreateParams.ts new file mode 100644 index 000000000000..3174e1ba896d --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/WorktreeCreateParams.ts @@ -0,0 +1,13 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { WorktreeDirtyPolicy } from "./WorktreeDirtyPolicy"; + +/** + * Create or reuse a managed worktree from the repository containing \`cwd\`. + */ +export type WorktreeCreateParams = { +/** + * Repository-relative workspace cwd to use as the source checkout. + */ +cwd?: string | null, branch: string, baseRef?: string | null, dirtyPolicy: WorktreeDirtyPolicy, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/WorktreeCreateResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/WorktreeCreateResponse.ts new file mode 100644 index 000000000000..f3589015255e --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/WorktreeCreateResponse.ts @@ -0,0 +1,10 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { WorktreeInfo } from "./WorktreeInfo"; +import type { WorktreeWarning } from "./WorktreeWarning"; + +/** + * Result returned by \`worktree/create\`. + */ +export type WorktreeCreateResponse = { reused: boolean, info: WorktreeInfo, warnings: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/WorktreeDirtyPolicy.ts b/codex-rs/app-server-protocol/schema/typescript/v2/WorktreeDirtyPolicy.ts new file mode 100644 index 000000000000..8f3287fbecc5 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/WorktreeDirtyPolicy.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type WorktreeDirtyPolicy = "fail" | "ignore" | "copyTracked" | "copyAll" | "moveTracked" | "moveAll"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/WorktreeDirtyState.ts b/codex-rs/app-server-protocol/schema/typescript/v2/WorktreeDirtyState.ts new file mode 100644 index 000000000000..0f790dbb9ccd --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/WorktreeDirtyState.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type WorktreeDirtyState = { hasStagedChanges: boolean, hasUnstagedChanges: boolean, hasUntrackedFiles: boolean, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/WorktreeInfo.ts b/codex-rs/app-server-protocol/schema/typescript/v2/WorktreeInfo.ts new file mode 100644 index 000000000000..4d25ca727fdb --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/WorktreeInfo.ts @@ -0,0 +1,11 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { WorktreeDirtyState } from "./WorktreeDirtyState"; +import type { WorktreeLocation } from "./WorktreeLocation"; +import type { WorktreeSource } from "./WorktreeSource"; + +/** + * Server-native representation of a managed worktree. + */ +export type WorktreeInfo = { id: string, name: string, slug: string, source: WorktreeSource, location: WorktreeLocation, repoName: string, repoRoot: string, commonGitDir: string, worktreeGitRoot: string, workspaceCwd: string, originalRelativeCwd: string, branch: string | null, head: string | null, ownerThreadId: string | null, metadataPath: string, dirty: WorktreeDirtyState, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/WorktreeInspectSourceParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/WorktreeInspectSourceParams.ts new file mode 100644 index 000000000000..88527cc36b64 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/WorktreeInspectSourceParams.ts @@ -0,0 +1,12 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Inspect dirty state for the repository containing \`cwd\`. + */ +export type WorktreeInspectSourceParams = { +/** + * Repository-relative workspace cwd to inspect. Omitted uses app-server's effective cwd. + */ +cwd?: string | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/WorktreeInspectSourceResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/WorktreeInspectSourceResponse.ts new file mode 100644 index 000000000000..eb09c216f40a --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/WorktreeInspectSourceResponse.ts @@ -0,0 +1,9 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { WorktreeDirtyState } from "./WorktreeDirtyState"; + +/** + * Dirty-state response returned by \`worktree/inspectSource\`. + */ +export type WorktreeInspectSourceResponse = { dirty: WorktreeDirtyState, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/WorktreeListParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/WorktreeListParams.ts new file mode 100644 index 000000000000..c94e410ad3ab --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/WorktreeListParams.ts @@ -0,0 +1,12 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Request the managed worktrees associated with the repository containing \`cwd\`. + */ +export type WorktreeListParams = { +/** + * Repository-relative workspace cwd to inspect. Omitted uses app-server's effective cwd. + */ +cwd?: string | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/WorktreeListResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/WorktreeListResponse.ts new file mode 100644 index 000000000000..4d840eab16cc --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/WorktreeListResponse.ts @@ -0,0 +1,9 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { WorktreeInfo } from "./WorktreeInfo"; + +/** + * Managed worktrees returned by \`worktree/list\`. + */ +export type WorktreeListResponse = { data: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/WorktreeLocation.ts b/codex-rs/app-server-protocol/schema/typescript/v2/WorktreeLocation.ts new file mode 100644 index 000000000000..100c8f42233c --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/WorktreeLocation.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type WorktreeLocation = "sibling" | "codexHome" | "external"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/WorktreeRemoveParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/WorktreeRemoveParams.ts new file mode 100644 index 000000000000..effd01ea75b3 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/WorktreeRemoveParams.ts @@ -0,0 +1,12 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Remove a managed worktree in the repository containing \`cwd\`. + */ +export type WorktreeRemoveParams = { +/** + * Repository-relative workspace cwd to use when resolving \`name_or_path\`. + */ +cwd?: string | null, nameOrPath: string, force?: boolean, deleteBranch?: boolean, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/WorktreeRemoveResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/WorktreeRemoveResponse.ts new file mode 100644 index 000000000000..b339b9321850 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/WorktreeRemoveResponse.ts @@ -0,0 +1,8 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Result returned by \`worktree/remove\`. + */ +export type WorktreeRemoveResponse = { removedPath: string, deletedBranch: string | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/WorktreeSource.ts b/codex-rs/app-server-protocol/schema/typescript/v2/WorktreeSource.ts new file mode 100644 index 000000000000..28907ccedc69 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/WorktreeSource.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type WorktreeSource = "cli" | "app" | "legacy" | "git"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/WorktreeWarning.ts b/codex-rs/app-server-protocol/schema/typescript/v2/WorktreeWarning.ts new file mode 100644 index 000000000000..de2481bdf335 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/WorktreeWarning.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type WorktreeWarning = { message: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index 6901e2a040e8..f894ec2e330c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -452,4 +452,18 @@ export type { WindowsSandboxSetupMode } from "./WindowsSandboxSetupMode"; export type { WindowsSandboxSetupStartParams } from "./WindowsSandboxSetupStartParams"; export type { WindowsSandboxSetupStartResponse } from "./WindowsSandboxSetupStartResponse"; export type { WindowsWorldWritableWarningNotification } from "./WindowsWorldWritableWarningNotification"; +export type { WorktreeCreateParams } from "./WorktreeCreateParams"; +export type { WorktreeCreateResponse } from "./WorktreeCreateResponse"; +export type { WorktreeDirtyPolicy } from "./WorktreeDirtyPolicy"; +export type { WorktreeDirtyState } from "./WorktreeDirtyState"; +export type { WorktreeInfo } from "./WorktreeInfo"; +export type { WorktreeInspectSourceParams } from "./WorktreeInspectSourceParams"; +export type { WorktreeInspectSourceResponse } from "./WorktreeInspectSourceResponse"; +export type { WorktreeListParams } from "./WorktreeListParams"; +export type { WorktreeListResponse } from "./WorktreeListResponse"; +export type { WorktreeLocation } from "./WorktreeLocation"; +export type { WorktreeRemoveParams } from "./WorktreeRemoveParams"; +export type { WorktreeRemoveResponse } from "./WorktreeRemoveResponse"; +export type { WorktreeSource } from "./WorktreeSource"; +export type { WorktreeWarning } from "./WorktreeWarning"; export type { WriteStatus } from "./WriteStatus"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 682556b976e3..ae008b496be3 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -710,6 +710,26 @@ client_request_definitions! { serialization: fs_watch_id(params.watch_id), response: v2::FsUnwatchResponse, }, + WorktreeList => "worktree/list" { + params: v2::WorktreeListParams, + serialization: None, + response: v2::WorktreeListResponse, + }, + WorktreeInspectSource => "worktree/inspectSource" { + params: v2::WorktreeInspectSourceParams, + serialization: None, + response: v2::WorktreeInspectSourceResponse, + }, + WorktreeCreate => "worktree/create" { + params: v2::WorktreeCreateParams, + serialization: global("worktree"), + response: v2::WorktreeCreateResponse, + }, + WorktreeRemove => "worktree/remove" { + params: v2::WorktreeRemoveParams, + serialization: global("worktree"), + response: v2::WorktreeRemoveResponse, + }, SkillsConfigWrite => "skills/config/write" { params: v2::SkillsConfigWriteParams, serialization: global("config"), diff --git a/codex-rs/app-server-protocol/src/protocol/v2/mod.rs b/codex-rs/app-server-protocol/src/protocol/v2/mod.rs index df8a363f827e..ad42808bdd8d 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/mod.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/mod.rs @@ -23,6 +23,7 @@ mod thread; mod thread_data; mod turn; mod windows_sandbox; +mod worktree; pub use account::*; pub use apps::*; @@ -48,6 +49,7 @@ pub use thread::*; pub use thread_data::*; pub use turn::*; pub use windows_sandbox::*; +pub use worktree::*; #[cfg(test)] mod tests; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/worktree.rs b/codex-rs/app-server-protocol/src/protocol/v2/worktree.rs new file mode 100644 index 000000000000..35c802255a03 --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/worktree.rs @@ -0,0 +1,158 @@ +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use ts_rs::TS; + +/// Request the managed worktrees associated with the repository containing \`cwd\`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct WorktreeListParams { + /// Repository-relative workspace cwd to inspect. Omitted uses app-server's effective cwd. + #[ts(optional = nullable)] + pub cwd: Option, +} + +/// Managed worktrees returned by \`worktree/list\`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct WorktreeListResponse { + pub data: Vec, +} + +/// Inspect dirty state for the repository containing \`cwd\`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct WorktreeInspectSourceParams { + /// Repository-relative workspace cwd to inspect. Omitted uses app-server's effective cwd. + #[ts(optional = nullable)] + pub cwd: Option, +} + +/// Dirty-state response returned by \`worktree/inspectSource\`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct WorktreeInspectSourceResponse { + pub dirty: WorktreeDirtyState, +} + +/// Create or reuse a managed worktree from the repository containing \`cwd\`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct WorktreeCreateParams { + /// Repository-relative workspace cwd to use as the source checkout. + #[ts(optional = nullable)] + pub cwd: Option, + pub branch: String, + #[ts(optional = nullable)] + pub base_ref: Option, + pub dirty_policy: WorktreeDirtyPolicy, +} + +/// Result returned by \`worktree/create\`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct WorktreeCreateResponse { + pub reused: bool, + pub info: WorktreeInfo, + pub warnings: Vec, +} + +/// Remove a managed worktree in the repository containing \`cwd\`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct WorktreeRemoveParams { + /// Repository-relative workspace cwd to use when resolving \`name_or_path\`. + #[ts(optional = nullable)] + pub cwd: Option, + pub name_or_path: String, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub force: bool, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub delete_branch: bool, +} + +/// Result returned by \`worktree/remove\`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct WorktreeRemoveResponse { + pub removed_path: String, + pub deleted_branch: Option, +} + +/// Server-native representation of a managed worktree. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct WorktreeInfo { + pub id: String, + pub name: String, + pub slug: String, + pub source: WorktreeSource, + pub location: WorktreeLocation, + pub repo_name: String, + pub repo_root: String, + pub common_git_dir: String, + pub worktree_git_root: String, + pub workspace_cwd: String, + pub original_relative_cwd: String, + pub branch: Option, + pub head: Option, + pub owner_thread_id: Option, + pub metadata_path: String, + pub dirty: WorktreeDirtyState, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum WorktreeSource { + Cli, + App, + Legacy, + Git, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum WorktreeLocation { + Sibling, + CodexHome, + External, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum WorktreeDirtyPolicy { + Fail, + Ignore, + CopyTracked, + CopyAll, + MoveTracked, + MoveAll, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct WorktreeDirtyState { + pub has_staged_changes: bool, + pub has_unstaged_changes: bool, + pub has_untracked_files: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct WorktreeWarning { + pub message: String, +} diff --git a/codex-rs/app-server/Cargo.toml b/codex-rs/app-server/Cargo.toml index 6d201bdee3f1..8416cd0981a5 100644 --- a/codex-rs/app-server/Cargo.toml +++ b/codex-rs/app-server/Cargo.toml @@ -67,6 +67,7 @@ codex-thread-store = { workspace = true } codex-tools = { workspace = true } codex-utils-absolute-path = { workspace = true } codex-utils-json-to-toml = { workspace = true } +codex-worktree = { workspace = true } chrono = { workspace = true } clap = { workspace = true, features = ["derive"] } futures = { workspace = true } diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index ddc381795272..421450c5e7ae 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -1150,6 +1150,13 @@ All filesystem paths in this section must be absolute. { "id": 45, "result": {} } ``` +### Worktrees + +- `worktree/list` - list Codex-managed worktrees for the repository containing `cwd`; omitted `cwd` uses the app server's effective cwd. +- `worktree/inspectSource` - inspect staged, unstaged, and untracked dirty state for the repository containing `cwd`. +- `worktree/create` - create or reuse a managed worktree from `cwd`, returning the resolved worktree plus any transfer warnings. +- `worktree/remove` - remove a managed worktree resolved from `cwd` and `nameOrPath`, with optional `force` and `deleteBranch` behavior. + ## Events Event notifications are the server-initiated event stream for thread lifecycles, turn lifecycles, and the items within them. After you start or resume a thread, keep reading stdout for `thread/started`, `thread/archived`, `thread/unarchived`, `thread/closed`, `turn/*`, and `item/*` notifications. diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index a3c3877fdca9..208983d65137 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -32,6 +32,7 @@ use crate::request_processors::ThreadGoalRequestProcessor; use crate::request_processors::ThreadRequestProcessor; use crate::request_processors::TurnRequestProcessor; use crate::request_processors::WindowsSandboxRequestProcessor; +use crate::request_processors::WorktreeRequestProcessor; use crate::request_serialization::QueuedInitializedRequest; use crate::request_serialization::RequestSerializationQueueKey; use crate::request_serialization::RequestSerializationQueues; @@ -175,6 +176,7 @@ pub(crate) struct MessageProcessor { thread_processor: ThreadRequestProcessor, turn_processor: TurnRequestProcessor, windows_sandbox_processor: WindowsSandboxRequestProcessor, + worktree_processor: WorktreeRequestProcessor, request_serialization_queues: RequestSerializationQueues, } @@ -456,8 +458,9 @@ impl MessageProcessor { let windows_sandbox_processor = WindowsSandboxRequestProcessor::new( outgoing.clone(), Arc::clone(&config), - config_manager, + config_manager.clone(), ); + let worktree_processor = WorktreeRequestProcessor::new(config_manager); Self { outgoing, @@ -481,6 +484,7 @@ impl MessageProcessor { thread_processor, turn_processor, windows_sandbox_processor, + worktree_processor, request_serialization_queues: RequestSerializationQueues::default(), } } @@ -937,6 +941,26 @@ impl MessageProcessor { .unwatch(connection_id, params) .await .map(|response| Some(response.into())), + ClientRequest::WorktreeList { params, .. } => self + .worktree_processor + .list(params) + .await + .map(|response| Some(response.into())), + ClientRequest::WorktreeInspectSource { params, .. } => self + .worktree_processor + .inspect_source(params) + .await + .map(|response| Some(response.into())), + ClientRequest::WorktreeCreate { params, .. } => self + .worktree_processor + .create(params) + .await + .map(|response| Some(response.into())), + ClientRequest::WorktreeRemove { params, .. } => self + .worktree_processor + .remove(params) + .await + .map(|response| Some(response.into())), ClientRequest::ModelProviderCapabilitiesRead { params: _, .. } => self .config_processor .model_provider_capabilities_read() diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index 9f419d7006f2..ad9845192cb2 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -241,6 +241,20 @@ use codex_app_server_protocol::WindowsSandboxSetupCompletedNotification; use codex_app_server_protocol::WindowsSandboxSetupMode; use codex_app_server_protocol::WindowsSandboxSetupStartParams; use codex_app_server_protocol::WindowsSandboxSetupStartResponse; +use codex_app_server_protocol::WorktreeCreateParams; +use codex_app_server_protocol::WorktreeCreateResponse; +use codex_app_server_protocol::WorktreeDirtyPolicy as ApiWorktreeDirtyPolicy; +use codex_app_server_protocol::WorktreeDirtyState as ApiWorktreeDirtyState; +use codex_app_server_protocol::WorktreeInfo as ApiWorktreeInfo; +use codex_app_server_protocol::WorktreeInspectSourceParams; +use codex_app_server_protocol::WorktreeInspectSourceResponse; +use codex_app_server_protocol::WorktreeListParams; +use codex_app_server_protocol::WorktreeListResponse; +use codex_app_server_protocol::WorktreeLocation as ApiWorktreeLocation; +use codex_app_server_protocol::WorktreeRemoveParams; +use codex_app_server_protocol::WorktreeRemoveResponse; +use codex_app_server_protocol::WorktreeSource as ApiWorktreeSource; +use codex_app_server_protocol::WorktreeWarning as ApiWorktreeWarning; use codex_arg0::Arg0DispatchPaths; use codex_backend_client::AddCreditsNudgeCreditType as BackendAddCreditsNudgeCreditType; use codex_backend_client::Client as BackendClient; @@ -446,6 +460,7 @@ mod thread_processor; mod token_usage_replay; mod turn_processor; mod windows_sandbox_processor; +mod worktree_processor; pub(crate) use account_processor::AccountRequestProcessor; pub(crate) use apps_processor::AppsRequestProcessor; @@ -467,6 +482,7 @@ pub(crate) use thread_goal_processor::ThreadGoalRequestProcessor; pub(crate) use thread_processor::ThreadRequestProcessor; pub(crate) use turn_processor::TurnRequestProcessor; pub(crate) use windows_sandbox_processor::WindowsSandboxRequestProcessor; +pub(crate) use worktree_processor::WorktreeRequestProcessor; use crate::error_code::internal_error; use crate::error_code::invalid_request; diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs index 783e53ccb09f..326bb08d7560 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -1044,6 +1044,7 @@ impl ThreadRequestProcessor { .collect() }; let core_dynamic_tool_count = core_dynamic_tools.len(); + let codex_home = config.codex_home.to_path_buf(); let NewThread { thread_id, @@ -1147,6 +1148,11 @@ impl ThreadRequestProcessor { ); let active_permission_profile = thread_response_active_permission_profile(config_snapshot.active_permission_profile); + bind_worktree_thread_best_effort( + codex_home.as_path(), + config_snapshot.cwd.as_path(), + &thread.id, + ); let response = ThreadStartResponse { thread: thread.clone(), @@ -2480,6 +2486,11 @@ impl ThreadRequestProcessor { let active_permission_profile = thread_response_active_permission_profile( config_snapshot.active_permission_profile, ); + bind_worktree_thread_best_effort( + self.config.codex_home.as_path(), + session_configured.cwd.as_path(), + &thread.id, + ); let response = ThreadResumeResponse { thread, @@ -3127,6 +3138,11 @@ impl ThreadRequestProcessor { ); let active_permission_profile = thread_response_active_permission_profile(config_snapshot.active_permission_profile); + bind_worktree_thread_best_effort( + self.config.codex_home.as_path(), + session_configured.cwd.as_path(), + &thread.id, + ); let response = ThreadForkResponse { thread: thread.clone(), @@ -3325,6 +3341,24 @@ impl ThreadRequestProcessor { } } +fn bind_worktree_thread_best_effort( + codex_home: &std::path::Path, + cwd: &std::path::Path, + thread_id: &str, +) { + match codex_worktree::resolve_worktree(codex_home, cwd) { + Ok(Some(_)) => { + if let Err(err) = codex_worktree::bind_thread(cwd, thread_id) { + tracing::warn!(?err, "failed to bind managed worktree to thread"); + } + } + Ok(None) => {} + Err(err) => { + tracing::warn!(?err, "failed to resolve managed worktree metadata"); + } + } +} + fn xcode_26_4_mcp_elicitations_auto_deny( client_name: Option<&str>, client_version: Option<&str>, diff --git a/codex-rs/app-server/src/request_processors/worktree_processor.rs b/codex-rs/app-server/src/request_processors/worktree_processor.rs new file mode 100644 index 000000000000..0e55356691ae --- /dev/null +++ b/codex-rs/app-server/src/request_processors/worktree_processor.rs @@ -0,0 +1,201 @@ +use super::*; + +use std::path::PathBuf; + +use codex_worktree::DirtyPolicy; +use codex_worktree::DirtyState; +use codex_worktree::WorktreeInfo; +use codex_worktree::WorktreeListQuery; +use codex_worktree::WorktreeLocation; +use codex_worktree::WorktreeRemoveRequest; +use codex_worktree::WorktreeRequest; +use codex_worktree::WorktreeSource; +use codex_worktree::WorktreeWarning; + +#[derive(Clone)] +pub(crate) struct WorktreeRequestProcessor { + config_manager: ConfigManager, +} + +impl WorktreeRequestProcessor { + pub(crate) fn new(config_manager: ConfigManager) -> Self { + Self { config_manager } + } + + pub(crate) async fn list( + &self, + params: WorktreeListParams, + ) -> Result { + let cwd = self.resolve_cwd(params.cwd).await?; + let data = codex_worktree::list_worktrees(WorktreeListQuery { + codex_home: self.config_manager.codex_home().to_path_buf(), + source_cwd: Some(cwd), + include_all_repos: false, + }) + .map_err(map_worktree_error)? + .into_iter() + .map(api_worktree_info) + .collect(); + Ok(WorktreeListResponse { data }) + } + + pub(crate) async fn inspect_source( + &self, + params: WorktreeInspectSourceParams, + ) -> Result { + let cwd = self.resolve_cwd(params.cwd).await?; + let dirty = api_dirty_state(codex_worktree::dirty_state(&cwd).map_err(map_worktree_error)?); + Ok(WorktreeInspectSourceResponse { dirty }) + } + + pub(crate) async fn create( + &self, + params: WorktreeCreateParams, + ) -> Result { + let cwd = self.resolve_cwd(params.cwd).await?; + let resolution = codex_worktree::ensure_worktree(WorktreeRequest { + codex_home: self.config_manager.codex_home().to_path_buf(), + source_cwd: cwd, + branch: params.branch, + base_ref: params.base_ref, + dirty_policy: dirty_policy_from_api(params.dirty_policy), + }) + .map_err(map_worktree_error)?; + Ok(WorktreeCreateResponse { + reused: resolution.reused, + info: api_worktree_info(resolution.info), + warnings: resolution + .warnings + .into_iter() + .map(api_worktree_warning) + .collect(), + }) + } + + pub(crate) async fn remove( + &self, + params: WorktreeRemoveParams, + ) -> Result { + let cwd = self.resolve_cwd(params.cwd).await?; + let result = codex_worktree::remove_worktree(WorktreeRemoveRequest { + codex_home: self.config_manager.codex_home().to_path_buf(), + source_cwd: Some(cwd), + name_or_path: params.name_or_path, + force: params.force, + delete_branch: params.delete_branch, + }) + .map_err(map_worktree_error)?; + Ok(WorktreeRemoveResponse { + removed_path: result.removed_path.to_string_lossy().to_string(), + deleted_branch: result.deleted_branch, + }) + } + + async fn resolve_cwd(&self, cwd: Option) -> Result { + match cwd { + Some(cwd) => Ok(PathBuf::from(cwd)), + None => self + .config_manager + .load_latest_config(/*fallback_cwd*/ None) + .await + .map(|config| config.cwd.to_path_buf()) + .map_err(|err| internal_error(format!("failed to load worktree cwd: {err}"))), + } + } +} + +fn map_worktree_error(err: anyhow::Error) -> JSONRPCErrorError { + invalid_request(err.to_string()) +} + +fn dirty_policy_from_api(value: ApiWorktreeDirtyPolicy) -> DirtyPolicy { + match value { + ApiWorktreeDirtyPolicy::Fail => DirtyPolicy::Fail, + ApiWorktreeDirtyPolicy::Ignore => DirtyPolicy::Ignore, + ApiWorktreeDirtyPolicy::CopyTracked => DirtyPolicy::CopyTracked, + ApiWorktreeDirtyPolicy::CopyAll => DirtyPolicy::CopyAll, + ApiWorktreeDirtyPolicy::MoveTracked => DirtyPolicy::MoveTracked, + ApiWorktreeDirtyPolicy::MoveAll => DirtyPolicy::MoveAll, + } +} + +fn api_dirty_state(value: DirtyState) -> ApiWorktreeDirtyState { + ApiWorktreeDirtyState { + has_staged_changes: value.has_staged_changes, + has_unstaged_changes: value.has_unstaged_changes, + has_untracked_files: value.has_untracked_files, + } +} + +fn api_worktree_info(value: WorktreeInfo) -> ApiWorktreeInfo { + ApiWorktreeInfo { + id: value.id, + name: value.name, + slug: value.slug, + source: api_worktree_source(value.source), + location: api_worktree_location(value.location), + repo_name: value.repo_name, + repo_root: value.repo_root.to_string_lossy().to_string(), + common_git_dir: value.common_git_dir.to_string_lossy().to_string(), + worktree_git_root: value.worktree_git_root.to_string_lossy().to_string(), + workspace_cwd: value.workspace_cwd.to_string_lossy().to_string(), + original_relative_cwd: value.original_relative_cwd.to_string_lossy().to_string(), + branch: value.branch, + head: value.head, + owner_thread_id: value.owner_thread_id, + metadata_path: value.metadata_path.to_string_lossy().to_string(), + dirty: api_dirty_state(value.dirty), + } +} + +fn api_worktree_source(value: WorktreeSource) -> ApiWorktreeSource { + match value { + WorktreeSource::Cli => ApiWorktreeSource::Cli, + WorktreeSource::App => ApiWorktreeSource::App, + WorktreeSource::Legacy => ApiWorktreeSource::Legacy, + WorktreeSource::Git => ApiWorktreeSource::Git, + } +} + +fn api_worktree_location(value: WorktreeLocation) -> ApiWorktreeLocation { + match value { + WorktreeLocation::Sibling => ApiWorktreeLocation::Sibling, + WorktreeLocation::CodexHome => ApiWorktreeLocation::CodexHome, + WorktreeLocation::External => ApiWorktreeLocation::External, + } +} + +fn api_worktree_warning(value: WorktreeWarning) -> ApiWorktreeWarning { + ApiWorktreeWarning { + message: value.message, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn dirty_policy_conversion_preserves_every_variant() { + assert_eq!( + [ + ApiWorktreeDirtyPolicy::Fail, + ApiWorktreeDirtyPolicy::Ignore, + ApiWorktreeDirtyPolicy::CopyTracked, + ApiWorktreeDirtyPolicy::CopyAll, + ApiWorktreeDirtyPolicy::MoveTracked, + ApiWorktreeDirtyPolicy::MoveAll, + ] + .map(dirty_policy_from_api), + [ + DirtyPolicy::Fail, + DirtyPolicy::Ignore, + DirtyPolicy::CopyTracked, + DirtyPolicy::CopyAll, + DirtyPolicy::MoveTracked, + DirtyPolicy::MoveAll, + ] + ); + } +} diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index e4a8f861e45a..954f4227a0a4 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -760,14 +760,6 @@ async fn run_debug_app_server_command(cmd: DebugAppServerCommand) -> anyhow::Res } } -fn resolve_worktree_options_for_tui( - cli: &mut TuiCli, - remote: Option<&str>, - remote_auth_token_env: Option<&str>, -) -> anyhow::Result> { - resolve_worktree_options_for_shared_cli(&mut cli.shared, remote, remote_auth_token_env) -} - fn resolve_worktree_options_for_shared_cli( shared: &mut SharedCliOptions, remote: Option<&str>, @@ -1121,11 +1113,6 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { &mut interactive.config_overrides, root_config_overrides.clone(), ); - resolve_worktree_options_for_tui( - &mut interactive, - root_remote.as_deref(), - root_remote_auth_token_env.as_deref(), - )?; let exit_info = run_interactive_tui( interactive, root_remote.clone(), @@ -1299,14 +1286,6 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { include_non_interactive, config_overrides, ); - resolve_worktree_options_for_tui( - &mut interactive, - remote.remote.as_deref().or(root_remote.as_deref()), - remote - .remote_auth_token_env - .as_deref() - .or(root_remote_auth_token_env.as_deref()), - )?; let exit_info = run_interactive_tui( interactive, remote.remote.or(root_remote.clone()), @@ -1333,14 +1312,6 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { all, config_overrides, ); - resolve_worktree_options_for_tui( - &mut interactive, - remote.remote.as_deref().or(root_remote.as_deref()), - remote - .remote_auth_token_env - .as_deref() - .or(root_remote_auth_token_env.as_deref()), - )?; let exit_info = run_interactive_tui( interactive, remote.remote.or(root_remote.clone()), diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index ff3372139fc5..88f8bea20e61 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -199,10 +199,6 @@ impl App { self.open_worktree_base_ref_prompt(branch); tui.frame_requester().schedule_frame(); } - AppEvent::WorktreesLoaded { cwd, result } => { - self.on_worktrees_loaded(cwd, result); - tui.frame_requester().schedule_frame(); - } AppEvent::CreateWorktreeAndSwitch { branch, base_ref, diff --git a/codex-rs/tui/src/app/worktree.rs b/codex-rs/tui/src/app/worktree.rs index d1b1d8c560a3..191a359c174c 100644 --- a/codex-rs/tui/src/app/worktree.rs +++ b/codex-rs/tui/src/app/worktree.rs @@ -1,17 +1,32 @@ //! App-layer handlers for the worktree TUI flow. -use anyhow::Context; +use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::WorktreeCreateParams; +use codex_app_server_protocol::WorktreeCreateResponse; +use codex_app_server_protocol::WorktreeDirtyPolicy as ApiWorktreeDirtyPolicy; +use codex_app_server_protocol::WorktreeDirtyState as ApiWorktreeDirtyState; +use codex_app_server_protocol::WorktreeInfo as ApiWorktreeInfo; +use codex_app_server_protocol::WorktreeInspectSourceParams; +use codex_app_server_protocol::WorktreeInspectSourceResponse; +use codex_app_server_protocol::WorktreeListParams; +use codex_app_server_protocol::WorktreeListResponse; +use codex_app_server_protocol::WorktreeLocation as ApiWorktreeLocation; +use codex_app_server_protocol::WorktreeRemoveParams; +use codex_app_server_protocol::WorktreeRemoveResponse; +use codex_app_server_protocol::WorktreeSource as ApiWorktreeSource; use codex_protocol::ThreadId; use codex_worktree::DirtyPolicy; use codex_worktree::WorktreeInfo; -use codex_worktree::WorktreeListQuery; -use codex_worktree::WorktreeRemoveRequest; +use codex_worktree::WorktreeLocation; use codex_worktree::WorktreeRequest; use codex_worktree::WorktreeResolution; use codex_worktree::WorktreeSource; +use codex_worktree::WorktreeWarning; use std::path::Path; use std::path::PathBuf; use std::time::Duration; +use uuid::Uuid; use super::*; use crate::bottom_pane::custom_prompt_view::CustomPromptView; @@ -50,15 +65,11 @@ impl App { tui.frame_requester(), self.config.animations, )); - if self.remote_app_server_url.is_some() { - let result = self - .list_current_repo_worktrees_remote(app_server) - .await - .map_err(|err| err.to_string()); - self.on_worktrees_loaded(self.session_workspace_cwd(app_server).to_path_buf(), result); - } else { - self.fetch_worktrees_for_picker(); - } + let result = self + .list_current_repo_worktrees_for_session(app_server) + .await + .map_err(|err| err.to_string()); + self.on_worktrees_loaded(self.session_workspace_cwd(app_server).to_path_buf(), result); } pub(super) fn open_worktree_create_prompt(&mut self) { @@ -291,35 +302,12 @@ impl App { return; } - let result = if self.remote_app_server_url.is_some() { - let Some(runner) = self.workspace_command_runner.clone() else { - self.chat_widget.add_error_message( - "Remote worktree removal is unavailable because the workspace command runner is missing." - .to_string(), - ); - return; - }; - crate::remote_worktree::remove_worktree( - &runner, - &app_server.request_handle(), - self.session_workspace_cwd(app_server), - &target, - force, - delete_branch, - ) - .await - } else { - codex_worktree::remove_worktree(WorktreeRemoveRequest { - codex_home: self.config.codex_home.to_path_buf(), - source_cwd: Some(self.session_workspace_cwd(app_server).to_path_buf()), - name_or_path: target.clone(), - force, - delete_branch, - }) - }; + let result = self + .remove_worktree_via_app_server(app_server, target.clone(), force, delete_branch) + .await; match result { Ok(result) => { - let mut message = format!("Removed worktree {}", result.removed_path.display()); + let mut message = format!("Removed worktree {}", result.removed_path); if let Some(branch) = result.deleted_branch { message.push_str(&format!(" and deleted branch {branch}")); } @@ -332,58 +320,40 @@ impl App { } } - fn list_current_repo_worktrees(&self) -> anyhow::Result> { - codex_worktree::list_worktrees(WorktreeListQuery { - codex_home: self.config.codex_home.to_path_buf(), - source_cwd: Some(self.config.cwd.to_path_buf()), - include_all_repos: false, - }) - } - async fn list_current_repo_worktrees_for_session( &self, app_server: &AppServerSession, ) -> anyhow::Result> { - if self.remote_app_server_url.is_some() { - self.list_current_repo_worktrees_remote(app_server).await - } else { - self.list_current_repo_worktrees() - } - } - - async fn list_current_repo_worktrees_remote( - &self, - app_server: &AppServerSession, - ) -> anyhow::Result> { - let runner = self - .workspace_command_runner - .clone() - .context("remote worktree operations require a workspace command runner")?; - crate::remote_worktree::list_current_repo_worktrees( - &runner, - &app_server.request_handle(), - self.session_workspace_cwd(app_server), - ) - .await + let response: WorktreeListResponse = app_server + .request_handle() + .request_typed(ClientRequest::WorktreeList { + request_id: worktree_request_id("worktree-list"), + params: WorktreeListParams { + cwd: Some(self.session_workspace_cwd(app_server).display().to_string()), + }, + }) + .await?; + Ok(response + .data + .into_iter() + .map(worktree_info_from_api) + .collect()) } async fn source_worktree_dirty_state( &self, app_server: &AppServerSession, ) -> anyhow::Result { - if self.remote_app_server_url.is_some() { - let runner = self - .workspace_command_runner - .clone() - .context("remote worktree operations require a workspace command runner")?; - crate::remote_worktree::source_dirty_state( - &runner, - self.session_workspace_cwd(app_server), - ) - .await - } else { - codex_worktree::dirty_state(self.config.cwd.as_path()) - } + let response: WorktreeInspectSourceResponse = app_server + .request_handle() + .request_typed(ClientRequest::WorktreeInspectSource { + request_id: worktree_request_id("worktree-inspect-source"), + params: WorktreeInspectSourceParams { + cwd: Some(self.session_workspace_cwd(app_server).display().to_string()), + }, + }) + .await?; + Ok(dirty_state_from_api(response.dirty)) } fn session_workspace_cwd<'a>(&'a self, app_server: &'a AppServerSession) -> &'a Path { @@ -401,20 +371,6 @@ impl App { } } - fn fetch_worktrees_for_picker(&mut self) { - let query = WorktreeListQuery { - codex_home: self.config.codex_home.to_path_buf(), - source_cwd: Some(self.config.cwd.to_path_buf()), - include_all_repos: false, - }; - let cwd = self.config.cwd.to_path_buf(); - let app_event_tx = self.app_event_tx.clone(); - tokio::task::spawn_blocking(move || { - let result = codex_worktree::list_worktrees(query).map_err(|err| err.to_string()); - app_event_tx.send(AppEvent::WorktreesLoaded { cwd, result }); - }); - } - fn spawn_worktree_create_request( &self, app_server: &AppServerSession, @@ -422,31 +378,24 @@ impl App { ) { let cwd = request.source_cwd.clone(); let app_event_tx = self.app_event_tx.clone(); - if self.remote_app_server_url.is_some() { - let Some(runner) = self.workspace_command_runner.clone() else { - app_event_tx.send(AppEvent::WorktreeCreated { - cwd, - result: Err( - "remote worktree operations require a workspace command runner".to_string(), - ), - }); - return; - }; - let request_handle = app_server.request_handle(); - tokio::spawn(async move { - let result = - crate::remote_worktree::ensure_worktree(&runner, &request_handle, request) - .await - .map_err(|err| err.to_string()); - app_event_tx.send(AppEvent::WorktreeCreated { cwd, result }); - }); - } else { - tokio::task::spawn_blocking(move || { - let result = - codex_worktree::ensure_worktree(request).map_err(|err| err.to_string()); - app_event_tx.send(AppEvent::WorktreeCreated { cwd, result }); - }); - } + let request_handle = app_server.request_handle(); + tokio::spawn(async move { + let result: Result = request_handle + .request_typed(ClientRequest::WorktreeCreate { + request_id: worktree_request_id("worktree-create"), + params: WorktreeCreateParams { + cwd: Some(request.source_cwd.display().to_string()), + branch: request.branch, + base_ref: request.base_ref, + dirty_policy: dirty_policy_to_api(request.dirty_policy), + }, + }) + .await; + let result = result + .map(worktree_resolution_from_api) + .map_err(|err| err.to_string()); + app_event_tx.send(AppEvent::WorktreeCreated { cwd, result }); + }); } async fn switch_to_worktree_info( @@ -680,6 +629,103 @@ impl App { self.file_search .update_search_dir(self.config.cwd.to_path_buf()); } + + async fn remove_worktree_via_app_server( + &self, + app_server: &AppServerSession, + target: String, + force: bool, + delete_branch: bool, + ) -> anyhow::Result { + app_server + .request_handle() + .request_typed(ClientRequest::WorktreeRemove { + request_id: worktree_request_id("worktree-remove"), + params: WorktreeRemoveParams { + cwd: Some(self.session_workspace_cwd(app_server).display().to_string()), + name_or_path: target, + force, + delete_branch, + }, + }) + .await + .map_err(Into::into) + } +} + +fn worktree_request_id(prefix: &str) -> RequestId { + RequestId::String(format!("{prefix}-{}", Uuid::new_v4())) +} + +fn dirty_policy_to_api(value: DirtyPolicy) -> ApiWorktreeDirtyPolicy { + match value { + DirtyPolicy::Fail => ApiWorktreeDirtyPolicy::Fail, + DirtyPolicy::Ignore => ApiWorktreeDirtyPolicy::Ignore, + DirtyPolicy::CopyTracked => ApiWorktreeDirtyPolicy::CopyTracked, + DirtyPolicy::CopyAll => ApiWorktreeDirtyPolicy::CopyAll, + DirtyPolicy::MoveTracked => ApiWorktreeDirtyPolicy::MoveTracked, + DirtyPolicy::MoveAll => ApiWorktreeDirtyPolicy::MoveAll, + } +} + +fn dirty_state_from_api(value: ApiWorktreeDirtyState) -> codex_worktree::DirtyState { + codex_worktree::DirtyState { + has_staged_changes: value.has_staged_changes, + has_unstaged_changes: value.has_unstaged_changes, + has_untracked_files: value.has_untracked_files, + } +} + +fn worktree_info_from_api(value: ApiWorktreeInfo) -> WorktreeInfo { + WorktreeInfo { + id: value.id, + name: value.name, + slug: value.slug, + source: worktree_source_from_api(value.source), + location: worktree_location_from_api(value.location), + repo_name: value.repo_name, + repo_root: PathBuf::from(value.repo_root), + common_git_dir: PathBuf::from(value.common_git_dir), + worktree_git_root: PathBuf::from(value.worktree_git_root), + workspace_cwd: PathBuf::from(value.workspace_cwd), + original_relative_cwd: PathBuf::from(value.original_relative_cwd), + branch: value.branch, + head: value.head, + owner_thread_id: value.owner_thread_id, + metadata_path: PathBuf::from(value.metadata_path), + dirty: dirty_state_from_api(value.dirty), + } +} + +fn worktree_source_from_api(value: ApiWorktreeSource) -> WorktreeSource { + match value { + ApiWorktreeSource::Cli => WorktreeSource::Cli, + ApiWorktreeSource::App => WorktreeSource::App, + ApiWorktreeSource::Legacy => WorktreeSource::Legacy, + ApiWorktreeSource::Git => WorktreeSource::Git, + } +} + +fn worktree_location_from_api(value: ApiWorktreeLocation) -> WorktreeLocation { + match value { + ApiWorktreeLocation::Sibling => WorktreeLocation::Sibling, + ApiWorktreeLocation::CodexHome => WorktreeLocation::CodexHome, + ApiWorktreeLocation::External => WorktreeLocation::External, + } +} + +fn worktree_resolution_from_api(value: WorktreeCreateResponse) -> WorktreeResolution { + WorktreeResolution { + reused: value.reused, + info: worktree_info_from_api(value.info), + warnings: value + .warnings + .into_iter() + .map(|warning| WorktreeWarning { + message: warning.message, + }) + .collect(), + } } fn worktree_session_message( diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 890802920445..3e64344c6700 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -205,12 +205,6 @@ pub(crate) enum AppEvent { branch: String, }, - /// Result of loading worktrees for the managed worktree picker. - WorktreesLoaded { - cwd: PathBuf, - result: Result, String>, - }, - /// Create or reuse a managed worktree and switch the TUI into it. CreateWorktreeAndSwitch { branch: String, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 08752630fcfa..7d417c3e5617 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -117,7 +117,6 @@ use color_eyre::eyre::ContextCompat; use color_eyre::eyre::Result; use color_eyre::eyre::WrapErr; use std::collections::HashMap; -use std::path::Path; use std::path::PathBuf; use uuid::Uuid; @@ -1430,7 +1429,6 @@ async fn thread_session_state_from_thread_start_response( config: &Config, thread_params_mode: ThreadParamsMode, ) -> Result { - bind_worktree_thread_best_effort(config, response.cwd.as_path(), &response.thread.id); let permission_profile = permission_profile_from_thread_response( &response.sandbox, response.permission_profile.as_ref(), @@ -1463,7 +1461,6 @@ async fn thread_session_state_from_thread_resume_response( config: &Config, thread_params_mode: ThreadParamsMode, ) -> Result { - bind_worktree_thread_best_effort(config, response.cwd.as_path(), &response.thread.id); let permission_profile = permission_profile_from_thread_response( &response.sandbox, response.permission_profile.as_ref(), @@ -1496,7 +1493,6 @@ async fn thread_session_state_from_thread_fork_response( config: &Config, thread_params_mode: ThreadParamsMode, ) -> Result { - bind_worktree_thread_best_effort(config, response.cwd.as_path(), &response.thread.id); let permission_profile = permission_profile_from_thread_response( &response.sandbox, response.permission_profile.as_ref(), @@ -1524,20 +1520,6 @@ async fn thread_session_state_from_thread_fork_response( .await } -fn bind_worktree_thread_best_effort(config: &Config, cwd: &Path, thread_id: &str) { - match codex_worktree::resolve_worktree(config.codex_home.as_path(), cwd) { - Ok(Some(_)) => { - if let Err(err) = codex_worktree::bind_thread(cwd, thread_id) { - tracing::warn!(?err, "failed to bind managed worktree to thread"); - } - } - Ok(None) => {} - Err(err) => { - tracing::warn!(?err, "failed to resolve managed worktree metadata"); - } - } -} - fn permission_profile_from_thread_response( sandbox: &codex_app_server_protocol::SandboxPolicy, permission_profile: Option<&codex_app_server_protocol::PermissionProfile>, diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 55b0c4ef1a27..38a5820d0a44 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -28,12 +28,17 @@ use codex_app_server_client::RemoteAppServerConnectArgs; use codex_app_server_protocol::Account as AppServerAccount; use codex_app_server_protocol::AskForApproval; use codex_app_server_protocol::AuthMode as AppServerAuthMode; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ConfigWarningNotification; +use codex_app_server_protocol::RequestId; use codex_app_server_protocol::Thread as AppServerThread; use codex_app_server_protocol::ThreadListCwdFilter; use codex_app_server_protocol::ThreadListParams; use codex_app_server_protocol::ThreadSortKey as AppServerThreadSortKey; use codex_app_server_protocol::ThreadSourceKind; +use codex_app_server_protocol::WorktreeCreateParams; +use codex_app_server_protocol::WorktreeCreateResponse; +use codex_app_server_protocol::WorktreeDirtyPolicy; use codex_cloud_requirements::cloud_requirements_loader_for_storage; use codex_config::CloudRequirementsLoader; use codex_config::ConfigLoadError; @@ -55,6 +60,7 @@ use codex_state::log_db; use codex_terminal_detection::terminal_info; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_absolute_path::canonicalize_existing_preserving_symlinks; +use codex_utils_cli::WorktreeDirtyCliArg; use codex_utils_oss::ensure_oss_provider_ready; use codex_utils_oss::get_default_model_for_oss_provider; use color_eyre::eyre::WrapErr; @@ -179,7 +185,6 @@ mod tui; mod ui_consts; pub(crate) mod update_action; pub use update_action::UpdateAction; -mod remote_worktree; mod worktree; mod worktree_labels; #[cfg(not(debug_assertions))] @@ -698,6 +703,39 @@ fn latest_session_cwd_filter<'a>( } } +async fn create_startup_worktree( + app_server: &AppServerSession, + cwd: Option<&Path>, + branch: String, + base_ref: Option, + dirty_policy: WorktreeDirtyCliArg, +) -> color_eyre::Result { + app_server + .request_handle() + .request_typed(ClientRequest::WorktreeCreate { + request_id: RequestId::String(format!("startup-worktree-{}", Uuid::new_v4())), + params: WorktreeCreateParams { + cwd: cwd.map(|cwd| cwd.display().to_string()), + branch, + base_ref, + dirty_policy: worktree_dirty_policy_from_cli(dirty_policy), + }, + }) + .await + .map_err(color_eyre::eyre::Report::msg) +} + +fn worktree_dirty_policy_from_cli(value: WorktreeDirtyCliArg) -> WorktreeDirtyPolicy { + match value { + WorktreeDirtyCliArg::Fail => WorktreeDirtyPolicy::Fail, + WorktreeDirtyCliArg::Ignore => WorktreeDirtyPolicy::Ignore, + WorktreeDirtyCliArg::CopyTracked => WorktreeDirtyPolicy::CopyTracked, + WorktreeDirtyCliArg::CopyAll => WorktreeDirtyPolicy::CopyAll, + WorktreeDirtyCliArg::MoveTracked => WorktreeDirtyPolicy::MoveTracked, + WorktreeDirtyCliArg::MoveAll => WorktreeDirtyPolicy::MoveAll, + } +} + pub async fn run_main( mut cli: Cli, arg0_paths: Arg0DispatchPaths, @@ -1089,11 +1127,11 @@ pub async fn run_main( #[allow(clippy::too_many_arguments)] async fn run_ratatui_app( - cli: Cli, + mut cli: Cli, arg0_paths: Arg0DispatchPaths, loader_overrides: LoaderOverrides, app_server_target: AppServerTarget, - remote_cwd_override: Option, + mut remote_cwd_override: Option, initial_config: Config, overrides: ConfigOverrides, cli_kv_overrides: Vec<(String, toml::Value)>, @@ -1175,6 +1213,51 @@ async fn run_ratatui_app( }, ); + let startup_worktree_requested = cli.worktree.is_some(); + let mut initial_config = initial_config; + if let Some(branch) = cli.worktree.take() { + let Some(startup_app_server) = app_server.as_mut() else { + unreachable!("app server should exist while resolving startup worktree"); + }; + let response = match create_startup_worktree( + startup_app_server, + if remote_mode { + remote_cwd_override.as_deref() + } else { + Some(initial_config.cwd.as_path()) + }, + branch, + cli.worktree_base.take(), + cli.worktree_dirty, + ) + .await + { + Ok(response) => response, + Err(err) => { + shutdown_app_server_if_present(app_server.take()).await; + terminal_restore_guard.restore_silently(); + session_log::log_session_end(); + return Err(err); + } + }; + let worktree_cwd = PathBuf::from(response.info.workspace_cwd.clone()); + if remote_mode { + remote_cwd_override = Some(worktree_cwd.clone()); + startup_app_server.set_remote_cwd_override(Some(worktree_cwd)); + } else { + initial_config = load_config_or_exit_with_fallback_cwd( + cli_kv_overrides.clone(), + overrides.clone(), + cloud_requirements.clone(), + Some(worktree_cwd), + ) + .await; + } + initial_config + .startup_warnings + .extend(response.warnings.into_iter().map(|warning| warning.message)); + } + let should_show_trust_screen_flag = !remote_mode && should_show_trust_screen(&initial_config); let mut trust_decision_was_made = false; let login_status = if initial_config.model_provider.requires_openai_auth { @@ -1391,7 +1474,7 @@ async fn run_ratatui_app( }; let current_cwd = config.cwd.clone(); - let allow_prompt = !remote_mode && cli.cwd.is_none(); + let allow_prompt = !remote_mode && cli.cwd.is_none() && !startup_worktree_requested; let action_and_target_session_if_resume_or_fork = match &session_selection { resume_picker::SessionSelection::Resume(target_session) => { Some((CwdPromptAction::Resume, target_session)) @@ -1403,7 +1486,7 @@ async fn run_ratatui_app( }; let fallback_cwd = match action_and_target_session_if_resume_or_fork { Some((action, target_session)) => { - if remote_mode { + if startup_worktree_requested || remote_mode { Some(current_cwd.to_path_buf()) } else { match resolve_cwd_for_resume_or_fork( diff --git a/codex-rs/tui/src/remote_worktree.rs b/codex-rs/tui/src/remote_worktree.rs deleted file mode 100644 index 0d537799b5c1..000000000000 --- a/codex-rs/tui/src/remote_worktree.rs +++ /dev/null @@ -1,1042 +0,0 @@ -use std::path::Path; -use std::path::PathBuf; - -use anyhow::Context; -use anyhow::Result; -use base64::Engine; -use base64::engine::general_purpose::STANDARD; -use codex_app_server_client::AppServerRequestHandle; -use codex_app_server_protocol::ClientRequest; -use codex_app_server_protocol::FsCopyParams; -use codex_app_server_protocol::FsCopyResponse; -use codex_app_server_protocol::FsCreateDirectoryParams; -use codex_app_server_protocol::FsCreateDirectoryResponse; -use codex_app_server_protocol::FsGetMetadataParams; -use codex_app_server_protocol::FsGetMetadataResponse; -use codex_app_server_protocol::FsReadFileParams; -use codex_app_server_protocol::FsReadFileResponse; -use codex_app_server_protocol::FsRemoveParams; -use codex_app_server_protocol::FsRemoveResponse; -use codex_app_server_protocol::FsWriteFileParams; -use codex_app_server_protocol::FsWriteFileResponse; -use codex_app_server_protocol::RequestId; -use codex_utils_absolute_path::AbsolutePathBuf; -use codex_worktree::DirtyPolicy; -use codex_worktree::DirtyState; -use codex_worktree::WorktreeInfo; -use codex_worktree::WorktreeLocation; -use codex_worktree::WorktreeMetadata; -use codex_worktree::WorktreeRemoveResult; -use codex_worktree::WorktreeRequest; -use codex_worktree::WorktreeResolution; -use codex_worktree::WorktreeSource; -use codex_worktree::WorktreeThreadMetadata; -use codex_worktree::WorktreeWarning; -use uuid::Uuid; - -use crate::workspace_command::WorkspaceCommand; -use crate::workspace_command::WorkspaceCommandRunner; - -pub(crate) async fn list_current_repo_worktrees( - runner: &WorkspaceCommandRunner, - request_handle: &AppServerRequestHandle, - source_cwd: &Path, -) -> Result> { - let repo = RemoteSourceRepo::resolve(runner, source_cwd).await?; - let worktrees = parse_worktree_list( - &git_stdout(runner, &repo.root, &["worktree", "list", "--porcelain"]).await?, - ); - let mut infos = Vec::new(); - for entry in worktrees { - infos.push( - info_from_existing_worktree(runner, request_handle, &entry.path, entry.branch, &repo) - .await?, - ); - } - Ok(infos) -} - -pub(crate) async fn source_dirty_state( - runner: &WorkspaceCommandRunner, - source_cwd: &Path, -) -> Result { - let repo = RemoteSourceRepo::resolve(runner, source_cwd).await?; - dirty_state(runner, &repo.root).await -} - -pub(crate) async fn ensure_worktree( - runner: &WorkspaceCommandRunner, - request_handle: &AppServerRequestHandle, - req: WorktreeRequest, -) -> Result { - let repo = RemoteSourceRepo::resolve(runner, &req.source_cwd).await?; - let branch = req.branch.clone(); - git_status( - runner, - &repo.root, - &["check-ref-format", "--branch", &branch], - ) - .await?; - let worktree_git_root = codex_worktree::sibling_worktree_git_root(&repo.primary_root, &branch)?; - let workspace_cwd = worktree_git_root.join(&repo.relative_cwd); - - if path_exists(request_handle, &worktree_git_root).await? { - let metadata = read_metadata(runner, request_handle, &worktree_git_root) - .await? - .context("managed worktree path already exists but is not owned by Codex")?; - if metadata.branch.as_deref() != Some(branch.as_str()) && metadata.name != branch { - anyhow::bail!( - "managed worktree path {} is already used by {}; choose a different branch name", - worktree_git_root.display(), - metadata.branch.as_deref().unwrap_or(metadata.name.as_str()) - ); - } - let info = info_from_existing_worktree( - runner, - request_handle, - &worktree_git_root, - Some(branch), - &repo, - ) - .await?; - return Ok(WorktreeResolution { - reused: true, - info, - warnings: Vec::new(), - }); - } - - let worktrees = parse_worktree_list( - &git_stdout(runner, &repo.root, &["worktree", "list", "--porcelain"]).await?, - ); - if let Some(existing) = worktrees - .iter() - .find(|entry| entry.branch.as_deref() == Some(branch.as_str())) - && existing.path != worktree_git_root - { - anyhow::bail!( - "branch {branch} is already checked out at {}; remove that worktree first", - existing.path.display() - ); - } - - let warnings = - validate_dirty_policy_before_create(runner, &repo.root, req.dirty_policy).await?; - create_directory( - request_handle, - worktree_git_root - .parent() - .context("managed worktree path has no parent")?, - ) - .await?; - let branch_exists = git_status_result( - runner, - &repo.root, - &[ - "show-ref", - "--verify", - "--quiet", - &format!("refs/heads/{branch}"), - ], - ) - .await? - .success(); - let has_head = git_status_result(runner, &repo.root, &["rev-parse", "--verify", "HEAD"]) - .await? - .success(); - if branch_exists { - git_status( - runner, - &repo.root, - &[ - "worktree", - "add", - &worktree_git_root.to_string_lossy(), - &branch, - ], - ) - .await?; - } else if req.base_ref.is_none() && !has_head { - git_status( - runner, - &repo.root, - &[ - "worktree", - "add", - "--orphan", - "-b", - &branch, - &worktree_git_root.to_string_lossy(), - ], - ) - .await?; - } else { - let base_ref = req.base_ref.as_deref().unwrap_or("HEAD"); - git_status( - runner, - &repo.root, - &[ - "worktree", - "add", - "-b", - &branch, - &worktree_git_root.to_string_lossy(), - base_ref, - ], - ) - .await?; - } - - let prepared_transfer = match prepare_dirty_policy_after_create( - runner, - request_handle, - &repo.root, - &worktree_git_root, - req.dirty_policy, - ) - .await - { - Ok(prepared_transfer) => prepared_transfer, - Err(err) => { - return Err(rollback_failed_create( - runner, - &repo.root, - &worktree_git_root, - &branch, - /*delete_branch*/ !branch_exists, - err, - ) - .await); - } - }; - finalize_dirty_policy_after_create(runner, request_handle, &repo.root, prepared_transfer) - .await?; - let dirty = dirty_state(runner, &worktree_git_root).await?; - let head = git_stdout_result(runner, &worktree_git_root, &["rev-parse", "HEAD"]) - .await? - .success() - .then_some(async { git_stdout(runner, &worktree_git_root, &["rev-parse", "HEAD"]).await }); - let head = match head { - Some(future) => Some(future.await?), - None => None, - }; - let info_metadata_path = - metadata_path(runner, &worktree_git_root, "codex-worktree.json").await?; - let mut info = WorktreeInfo { - id: repo.id.clone(), - name: branch.clone(), - slug: codex_worktree::slugify_name(&branch)?, - source: WorktreeSource::Cli, - location: WorktreeLocation::Sibling, - repo_name: repo.repo_name.clone(), - repo_root: repo.root.clone(), - common_git_dir: repo.common_git_dir.clone(), - worktree_git_root: worktree_git_root.clone(), - workspace_cwd, - original_relative_cwd: repo.relative_cwd.clone(), - branch: Some(branch), - head, - owner_thread_id: None, - metadata_path: info_metadata_path, - dirty, - }; - write_json( - request_handle, - &metadata_path(runner, &worktree_git_root, "codex-thread.json").await?, - &WorktreeThreadMetadata { - version: 1, - owner_thread_id: None, - }, - ) - .await?; - let metadata = WorktreeMetadata::from_info(&info, repo.root); - write_json(request_handle, &info.metadata_path, &metadata).await?; - info.owner_thread_id = metadata.owner_thread_id; - Ok(WorktreeResolution { - reused: false, - info, - warnings: warnings - .into_iter() - .map(|message| WorktreeWarning { message }) - .collect(), - }) -} - -pub(crate) async fn remove_worktree( - runner: &WorkspaceCommandRunner, - request_handle: &AppServerRequestHandle, - source_cwd: &Path, - target: &str, - force: bool, - delete_branch: bool, -) -> Result { - let entries = list_current_repo_worktrees(runner, request_handle, source_cwd).await?; - let info = entries - .into_iter() - .find(|entry| { - entry.branch.as_deref() == Some(target) || entry.name == target || entry.slug == target - }) - .context("no managed worktree matched target")?; - let metadata = read_metadata(runner, request_handle, &info.worktree_git_root) - .await? - .context("refusing to remove a worktree not managed by Codex")?; - if metadata.source != WorktreeSource::Cli { - anyhow::bail!("refusing to remove a worktree not managed by Codex CLI"); - } - if info.dirty.is_dirty() && !force { - anyhow::bail!( - "refusing to remove dirty worktree {}; use --force to override", - info.worktree_git_root.display() - ); - } - let repo = RemoteSourceRepo::resolve(runner, source_cwd).await?; - let mut args = vec!["worktree", "remove"]; - if force { - args.push("--force"); - } - let target_path = info.worktree_git_root.to_string_lossy().to_string(); - args.push(&target_path); - git_status(runner, &repo.primary_root, &args).await?; - let mut deleted_branch = None; - if delete_branch && let Some(branch) = info.branch { - let delete_flag = if force { "-D" } else { "-d" }; - git_status( - runner, - &repo.primary_root, - &["branch", delete_flag, &branch], - ) - .await?; - deleted_branch = Some(branch); - } - Ok(WorktreeRemoveResult { - removed_path: info.worktree_git_root, - deleted_branch, - }) -} - -#[derive(Clone)] -struct RemoteSourceRepo { - root: PathBuf, - primary_root: PathBuf, - relative_cwd: PathBuf, - common_git_dir: PathBuf, - repo_name: String, - id: String, -} - -impl RemoteSourceRepo { - async fn resolve(runner: &WorkspaceCommandRunner, source_cwd: &Path) -> Result { - let root = - PathBuf::from(git_stdout(runner, source_cwd, &["rev-parse", "--show-toplevel"]).await?); - let common_git_dir_raw = - git_stdout(runner, source_cwd, &["rev-parse", "--git-common-dir"]).await?; - let common_git_dir = absolutize(source_cwd, Path::new(&common_git_dir_raw)); - let primary_root = parse_worktree_list( - &git_stdout(runner, &root, &["worktree", "list", "--porcelain"]).await?, - ) - .into_iter() - .next() - .map(|entry| entry.path) - .context("git did not report a primary worktree")?; - let origin = git_stdout_result(runner, &root, &["remote", "get-url", "origin"]).await?; - let origin = origin.success().then_some(origin.stdout.trim().to_string()); - let id = codex_worktree::repo_fingerprint(&common_git_dir, origin.as_deref()); - let repo_name = primary_root - .file_name() - .context("repository root has no directory name")? - .to_string_lossy() - .to_string(); - let relative_cwd = source_cwd - .strip_prefix(&root) - .unwrap_or_else(|_| Path::new("")) - .to_path_buf(); - Ok(Self { - root, - primary_root, - relative_cwd, - common_git_dir, - repo_name, - id, - }) - } -} - -async fn info_from_existing_worktree( - runner: &WorkspaceCommandRunner, - request_handle: &AppServerRequestHandle, - worktree_git_root: &Path, - fallback_branch: Option, - repo: &RemoteSourceRepo, -) -> Result { - let metadata = read_metadata(runner, request_handle, worktree_git_root).await?; - let branch = git_stdout_result( - runner, - worktree_git_root, - &["symbolic-ref", "--quiet", "--short", "HEAD"], - ) - .await? - .success() - .then_some(async { - git_stdout( - runner, - worktree_git_root, - &["symbolic-ref", "--quiet", "--short", "HEAD"], - ) - .await - }); - let branch = match branch { - Some(future) => Some(future.await?), - None => fallback_branch, - }; - let head = git_stdout_result(runner, worktree_git_root, &["rev-parse", "HEAD"]) - .await? - .success() - .then_some(async { git_stdout(runner, worktree_git_root, &["rev-parse", "HEAD"]).await }); - let head = match head { - Some(future) => Some(future.await?), - None => None, - }; - let dirty = dirty_state(runner, worktree_git_root) - .await - .unwrap_or_default(); - let name = metadata - .as_ref() - .map(|metadata| metadata.name.clone()) - .or_else(|| branch.clone()) - .unwrap_or_else(|| repo.repo_name.clone()); - let slug = metadata - .as_ref() - .map(|metadata| metadata.slug.clone()) - .unwrap_or_else(|| name.replace(['/', '\\'], "-")); - Ok(WorktreeInfo { - id: metadata - .as_ref() - .map(|metadata| metadata.repo_id.clone()) - .unwrap_or_else(|| repo.id.clone()), - name, - slug, - source: metadata - .as_ref() - .map(|metadata| metadata.source) - .unwrap_or(WorktreeSource::Git), - location: metadata - .as_ref() - .map(|metadata| metadata.location) - .unwrap_or(WorktreeLocation::External), - repo_name: repo.repo_name.clone(), - repo_root: repo.root.clone(), - common_git_dir: repo.common_git_dir.clone(), - worktree_git_root: worktree_git_root.to_path_buf(), - workspace_cwd: metadata - .as_ref() - .map(|metadata| metadata.workspace_cwd.clone()) - .unwrap_or_else(|| worktree_git_root.to_path_buf()), - original_relative_cwd: metadata - .as_ref() - .map(|metadata| metadata.original_relative_cwd.clone()) - .unwrap_or_default(), - branch, - head, - owner_thread_id: metadata.and_then(|metadata| metadata.owner_thread_id), - metadata_path: metadata_path(runner, worktree_git_root, "codex-worktree.json").await?, - dirty, - }) -} - -async fn dirty_state(runner: &WorkspaceCommandRunner, root: &Path) -> Result { - Ok(DirtyState { - has_staged_changes: !git_stdout(runner, root, &["diff", "--cached", "--name-only", "-z"]) - .await? - .is_empty(), - has_unstaged_changes: !git_stdout(runner, root, &["diff", "--name-only", "-z"]) - .await? - .is_empty(), - has_untracked_files: !git_stdout( - runner, - root, - &["ls-files", "--others", "--exclude-standard", "-z"], - ) - .await? - .is_empty(), - }) -} - -async fn validate_dirty_policy_before_create( - runner: &WorkspaceCommandRunner, - root: &Path, - policy: DirtyPolicy, -) -> Result> { - let state = dirty_state(runner, root).await?; - if !state.is_dirty() { - return Ok(Vec::new()); - } - match policy { - DirtyPolicy::Fail => anyhow::bail!( - "source checkout has uncommitted changes; use --worktree-dirty ignore, copy-tracked, copy-all, move-tracked, or move-all" - ), - DirtyPolicy::Ignore => Ok(vec![ - "source checkout has uncommitted changes; the new worktree was created without them" - .to_string(), - ]), - DirtyPolicy::CopyTracked | DirtyPolicy::MoveTracked if state.has_untracked_files => Ok(vec![ - "untracked files were left in the source checkout; use --worktree-dirty copy-all or move-all to carry them" - .to_string(), - ]), - DirtyPolicy::CopyTracked - | DirtyPolicy::CopyAll - | DirtyPolicy::MoveTracked - | DirtyPolicy::MoveAll => Ok(Vec::new()), - } -} - -async fn prepare_dirty_policy_after_create( - runner: &WorkspaceCommandRunner, - request_handle: &AppServerRequestHandle, - source_root: &Path, - worktree_root: &Path, - policy: DirtyPolicy, -) -> Result { - let state = dirty_state(runner, source_root).await?; - if !state.is_dirty() { - return Ok(PreparedRemoteDirtyTransfer { move_plan: None }); - } - let plan = RemoteTransferPlan::capture(runner, source_root).await?; - match policy { - DirtyPolicy::Fail | DirtyPolicy::Ignore => { - Ok(PreparedRemoteDirtyTransfer { move_plan: None }) - } - DirtyPolicy::CopyTracked => { - plan.apply_tracked_diff(runner, request_handle, worktree_root) - .await?; - Ok(PreparedRemoteDirtyTransfer { move_plan: None }) - } - DirtyPolicy::CopyAll => { - plan.apply_tracked_diff(runner, request_handle, worktree_root) - .await?; - plan.copy_untracked(request_handle, source_root, worktree_root) - .await?; - Ok(PreparedRemoteDirtyTransfer { move_plan: None }) - } - DirtyPolicy::MoveTracked => { - plan.apply_tracked_diff(runner, request_handle, worktree_root) - .await?; - Ok(PreparedRemoteDirtyTransfer { - move_plan: Some(RemoteMovePlan { - transfer: plan, - move_untracked: false, - }), - }) - } - DirtyPolicy::MoveAll => { - plan.apply_tracked_diff(runner, request_handle, worktree_root) - .await?; - plan.copy_untracked(request_handle, source_root, worktree_root) - .await?; - Ok(PreparedRemoteDirtyTransfer { - move_plan: Some(RemoteMovePlan { - transfer: plan, - move_untracked: true, - }), - }) - } - } -} - -async fn finalize_dirty_policy_after_create( - runner: &WorkspaceCommandRunner, - request_handle: &AppServerRequestHandle, - source_root: &Path, - prepared: PreparedRemoteDirtyTransfer, -) -> Result<()> { - let Some(move_plan) = prepared.move_plan else { - return Ok(()); - }; - move_plan - .transfer - .clean_source_after_move( - runner, - request_handle, - source_root, - move_plan.move_untracked, - ) - .await - .with_context(|| { - "worktree already contains transferred changes, but failed to clean the source checkout after move" - }) -} - -async fn rollback_failed_create( - runner: &WorkspaceCommandRunner, - repo_root: &Path, - worktree_git_root: &Path, - branch: &str, - delete_branch: bool, - err: anyhow::Error, -) -> anyhow::Error { - let mut rollback_errors = Vec::new(); - let worktree_arg = worktree_git_root.to_string_lossy().to_string(); - if let Err(rollback_err) = git_status( - runner, - repo_root, - &["worktree", "remove", "--force", &worktree_arg], - ) - .await - { - rollback_errors.push(rollback_err.to_string()); - } - if delete_branch - && let Err(rollback_err) = git_status(runner, repo_root, &["branch", "-D", branch]).await - { - rollback_errors.push(rollback_err.to_string()); - } - if rollback_errors.is_empty() { - err - } else { - anyhow::anyhow!( - "{err}; additionally failed to roll back newly-created worktree: {}", - rollback_errors.join("; ") - ) - } -} - -struct PreparedRemoteDirtyTransfer { - move_plan: Option, -} - -struct RemoteMovePlan { - transfer: RemoteTransferPlan, - move_untracked: bool, -} - -struct RemoteTransferPlan { - staged_diff: String, - unstaged_diff: String, - tracked_paths: Vec, - untracked_paths: Vec, -} - -impl RemoteTransferPlan { - async fn capture(runner: &WorkspaceCommandRunner, source_root: &Path) -> Result { - Ok(Self { - staged_diff: git_stdout_raw_uncapped( - runner, - source_root, - &["diff", "--cached", "--binary"], - ) - .await?, - unstaged_diff: git_stdout_raw_uncapped(runner, source_root, &["diff", "--binary"]) - .await?, - tracked_paths: tracked_paths(runner, source_root).await?, - untracked_paths: untracked_paths(runner, source_root).await?, - }) - } - - async fn apply_tracked_diff( - &self, - runner: &WorkspaceCommandRunner, - request_handle: &AppServerRequestHandle, - worktree_root: &Path, - ) -> Result<()> { - if !self.staged_diff.is_empty() { - apply_patch_file( - runner, - request_handle, - worktree_root, - "staged", - &self.staged_diff, - &["apply", "--index", "--binary"], - ) - .await?; - } - if !self.unstaged_diff.is_empty() { - apply_patch_file( - runner, - request_handle, - worktree_root, - "unstaged", - &self.unstaged_diff, - &["apply", "--binary"], - ) - .await?; - } - Ok(()) - } - - async fn copy_untracked( - &self, - request_handle: &AppServerRequestHandle, - source_root: &Path, - worktree_root: &Path, - ) -> Result<()> { - for relative_path in &self.untracked_paths { - if let Some(parent) = worktree_root.join(relative_path).parent() { - create_directory(request_handle, parent).await?; - } - fs_copy( - request_handle, - &source_root.join(relative_path), - &worktree_root.join(relative_path), - ) - .await?; - } - Ok(()) - } - - async fn clean_source_after_move( - &self, - runner: &WorkspaceCommandRunner, - request_handle: &AppServerRequestHandle, - source_root: &Path, - move_untracked: bool, - ) -> Result<()> { - if git_status_result(runner, source_root, &["rev-parse", "--verify", "HEAD"]) - .await? - .success() - { - git_status(runner, source_root, &["reset", "--hard", "HEAD"]).await?; - } else { - git_status(runner, source_root, &["read-tree", "--empty"]).await?; - for relative_path in &self.tracked_paths { - fs_remove(request_handle, &source_root.join(relative_path)).await?; - } - } - if move_untracked { - for relative_path in &self.untracked_paths { - fs_remove(request_handle, &source_root.join(relative_path)).await?; - } - } - Ok(()) - } -} - -async fn apply_patch_file( - runner: &WorkspaceCommandRunner, - request_handle: &AppServerRequestHandle, - cwd: &Path, - label: &str, - contents: &str, - git_args: &[&str], -) -> Result<()> { - let patch_path = metadata_path( - runner, - cwd, - &format!("codex-worktree-{label}-{}.patch", Uuid::new_v4()), - ) - .await?; - fs_write(request_handle, &patch_path, contents.as_bytes()).await?; - let mut args = git_args.to_vec(); - let patch_arg = patch_path.to_string_lossy().to_string(); - args.push(&patch_arg); - let result = git_status(runner, cwd, &args).await; - let _ = fs_remove(request_handle, &patch_path).await; - result -} - -async fn tracked_paths(runner: &WorkspaceCommandRunner, root: &Path) -> Result> { - let mut paths = paths_from_nul_separated( - &git_stdout(runner, root, &["diff", "--cached", "--name-only", "-z"]).await?, - ); - paths.extend(paths_from_nul_separated( - &git_stdout(runner, root, &["diff", "--name-only", "-z"]).await?, - )); - paths.sort(); - paths.dedup(); - Ok(paths) -} - -async fn untracked_paths(runner: &WorkspaceCommandRunner, root: &Path) -> Result> { - Ok(paths_from_nul_separated( - &git_stdout( - runner, - root, - &["ls-files", "--others", "--exclude-standard", "-z"], - ) - .await?, - )) -} - -fn paths_from_nul_separated(output: &str) -> Vec { - output - .split('\0') - .filter(|path| !path.is_empty()) - .map(PathBuf::from) - .collect() -} - -async fn read_metadata( - runner: &WorkspaceCommandRunner, - request_handle: &AppServerRequestHandle, - worktree_root: &Path, -) -> Result> { - let path = metadata_path(runner, worktree_root, "codex-worktree.json").await?; - if !path_exists(request_handle, &path).await? { - return Ok(None); - } - let contents = fs_read(request_handle, &path).await?; - Ok(Some(serde_json::from_slice(&contents)?)) -} - -async fn write_json( - request_handle: &AppServerRequestHandle, - path: &Path, - value: &T, -) -> Result<()> { - fs_write(request_handle, path, &serde_json::to_vec_pretty(value)?).await -} - -async fn metadata_path( - runner: &WorkspaceCommandRunner, - root: &Path, - name: &str, -) -> Result { - Ok(absolutize( - root, - Path::new(&git_stdout(runner, root, &["rev-parse", "--git-path", name]).await?), - )) -} - -fn absolutize(cwd: &Path, path: &Path) -> PathBuf { - if path.is_absolute() { - path.to_path_buf() - } else { - cwd.join(path) - } -} - -#[derive(Debug)] -struct GitWorktreeEntry { - path: PathBuf, - branch: Option, -} - -fn parse_worktree_list(output: &str) -> Vec { - let mut entries = Vec::new(); - let mut path = None; - let mut branch = None; - for line in output.lines().chain(std::iter::once("")) { - if line.is_empty() { - if let Some(path) = path.take() { - entries.push(GitWorktreeEntry { - path, - branch: branch.take(), - }); - } - continue; - } - if let Some(raw_path) = line.strip_prefix("worktree ") { - path = Some(PathBuf::from(raw_path)); - } else if let Some(raw_branch) = line.strip_prefix("branch ") { - branch = Some(raw_branch.trim_start_matches("refs/heads/").to_string()); - } - } - entries -} - -async fn git_stdout(runner: &WorkspaceCommandRunner, cwd: &Path, args: &[&str]) -> Result { - let output = git_stdout_result(runner, cwd, args).await?; - if !output.success() { - anyhow::bail!("git {} failed: {}", args.join(" "), output.stderr.trim()); - } - Ok(output.stdout.trim_end().to_string()) -} - -async fn git_stdout_raw_uncapped( - runner: &WorkspaceCommandRunner, - cwd: &Path, - args: &[&str], -) -> Result { - let argv = std::iter::once("git") - .chain(args.iter().copied()) - .collect::>(); - let output = runner - .run(WorkspaceCommand::new(argv).cwd(cwd).disable_output_cap()) - .await?; - if !output.success() { - anyhow::bail!("git {} failed: {}", args.join(" "), output.stderr.trim()); - } - Ok(output.stdout) -} - -async fn git_stdout_result( - runner: &WorkspaceCommandRunner, - cwd: &Path, - args: &[&str], -) -> Result { - let argv = std::iter::once("git") - .chain(args.iter().copied()) - .collect::>(); - Ok(runner.run(WorkspaceCommand::new(argv).cwd(cwd)).await?) -} - -async fn git_status(runner: &WorkspaceCommandRunner, cwd: &Path, args: &[&str]) -> Result<()> { - let output = git_status_result(runner, cwd, args).await?; - if !output.success() { - anyhow::bail!("git {} failed: {}", args.join(" "), output.stderr.trim()); - } - Ok(()) -} - -async fn git_status_result( - runner: &WorkspaceCommandRunner, - cwd: &Path, - args: &[&str], -) -> Result { - git_stdout_result(runner, cwd, args).await -} - -fn absolute_path(path: &Path) -> Result { - AbsolutePathBuf::from_absolute_path(path).map_err(Into::into) -} - -async fn path_exists(request_handle: &AppServerRequestHandle, path: &Path) -> Result { - let result: Result = request_handle - .request_typed(ClientRequest::FsGetMetadata { - request_id: RequestId::String(format!("worktree-fs-meta-{}", Uuid::new_v4())), - params: FsGetMetadataParams { - path: absolute_path(path)?, - }, - }) - .await; - Ok(result.is_ok()) -} - -async fn create_directory(request_handle: &AppServerRequestHandle, path: &Path) -> Result<()> { - let _: FsCreateDirectoryResponse = request_handle - .request_typed(ClientRequest::FsCreateDirectory { - request_id: RequestId::String(format!("worktree-fs-mkdir-{}", Uuid::new_v4())), - params: FsCreateDirectoryParams { - path: absolute_path(path)?, - recursive: Some(true), - }, - }) - .await?; - Ok(()) -} - -async fn fs_read(request_handle: &AppServerRequestHandle, path: &Path) -> Result> { - let response: FsReadFileResponse = request_handle - .request_typed(ClientRequest::FsReadFile { - request_id: RequestId::String(format!("worktree-fs-read-{}", Uuid::new_v4())), - params: FsReadFileParams { - path: absolute_path(path)?, - }, - }) - .await?; - Ok(STANDARD.decode(response.data_base64)?) -} - -async fn fs_write( - request_handle: &AppServerRequestHandle, - path: &Path, - bytes: &[u8], -) -> Result<()> { - let _: FsWriteFileResponse = request_handle - .request_typed(ClientRequest::FsWriteFile { - request_id: RequestId::String(format!("worktree-fs-write-{}", Uuid::new_v4())), - params: FsWriteFileParams { - path: absolute_path(path)?, - data_base64: STANDARD.encode(bytes), - }, - }) - .await?; - Ok(()) -} - -async fn fs_copy( - request_handle: &AppServerRequestHandle, - source_path: &Path, - destination_path: &Path, -) -> Result<()> { - let _: FsCopyResponse = request_handle - .request_typed(ClientRequest::FsCopy { - request_id: RequestId::String(format!("worktree-fs-copy-{}", Uuid::new_v4())), - params: FsCopyParams { - source_path: absolute_path(source_path)?, - destination_path: absolute_path(destination_path)?, - recursive: false, - }, - }) - .await?; - Ok(()) -} - -async fn fs_remove(request_handle: &AppServerRequestHandle, path: &Path) -> Result<()> { - let _: FsRemoveResponse = request_handle - .request_typed(ClientRequest::FsRemove { - request_id: RequestId::String(format!("worktree-fs-remove-{}", Uuid::new_v4())), - params: FsRemoveParams { - path: absolute_path(path)?, - recursive: Some(false), - force: Some(true), - }, - }) - .await?; - Ok(()) -} - -#[cfg(test)] -mod tests { - use std::future::Future; - use std::path::PathBuf; - use std::pin::Pin; - use std::sync::Arc; - use std::sync::Mutex; - - use pretty_assertions::assert_eq; - - use super::git_stdout_raw_uncapped; - use crate::workspace_command::WorkspaceCommand; - use crate::workspace_command::WorkspaceCommandError; - use crate::workspace_command::WorkspaceCommandExecutor; - use crate::workspace_command::WorkspaceCommandOutput; - use crate::workspace_command::WorkspaceCommandRunner; - - struct FakeRunner { - seen: Mutex>, - output: WorkspaceCommandOutput, - } - - impl WorkspaceCommandExecutor for FakeRunner { - fn run( - &self, - command: WorkspaceCommand, - ) -> Pin< - Box< - dyn Future> - + Send - + '_, - >, - > { - self.seen.lock().expect("seen lock").push(command); - Box::pin(async move { Ok(self.output.clone()) }) - } - } - - #[tokio::test] - async fn raw_git_stdout_preserves_patch_newline_and_disables_output_cap() { - let fake_runner = Arc::new(FakeRunner { - seen: Mutex::new(Vec::new()), - output: WorkspaceCommandOutput { - exit_code: 0, - stdout: "diff --git a/file b/file\n".to_string(), - stderr: String::new(), - }, - }); - let runner: WorkspaceCommandRunner = fake_runner.clone(); - - let output = git_stdout_raw_uncapped( - &runner, - &PathBuf::from("/repo"), - &["diff", "--cached", "--binary"], - ) - .await - .expect("raw git stdout"); - - assert_eq!(output, "diff --git a/file b/file\n"); - let seen = fake_runner.seen.lock().expect("seen lock"); - assert_eq!(seen.len(), 1); - assert_eq!(seen[0].argv, vec!["git", "diff", "--cached", "--binary"]); - assert_eq!(seen[0].cwd, Some(PathBuf::from("/repo"))); - assert!(seen[0].disable_output_cap); - } -} From 1276652068e3098af4005fb080869bd248f7655d Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Sun, 10 May 2026 12:21:03 -0300 Subject: [PATCH 11/12] refactor(worktree): route cli management through app-server --- CONTEXT.md | 69 ++++ codex-rs/Cargo.lock | 2 + .../schema/json/ClientRequest.json | 37 ++ .../codex_app_server_protocol.schemas.json | 56 +++ .../codex_app_server_protocol.v2.schemas.json | 56 +++ .../schema/json/v2/WorktreeListParams.json | 4 + .../schema/json/v2/WorktreePruneParams.json | 11 + .../schema/json/v2/WorktreePruneResponse.json | 17 + .../schema/typescript/ClientRequest.ts | 3 +- .../typescript/v2/WorktreeListParams.ts | 6 +- .../typescript/v2/WorktreePruneParams.ts | 8 + .../typescript/v2/WorktreePruneResponse.ts | 8 + .../schema/typescript/v2/index.ts | 2 + .../src/protocol/common.rs | 5 + .../src/protocol/v2/worktree.rs | 20 + codex-rs/app-server/README.md | 3 +- codex-rs/app-server/src/message_processor.rs | 5 + codex-rs/app-server/src/request_processors.rs | 2 + .../request_processors/worktree_processor.rs | 35 +- codex-rs/cli/Cargo.toml | 2 + codex-rs/cli/src/main.rs | 354 ++++++++++++------ codex-rs/tui/src/app/worktree.rs | 1 + codex-rs/worktree/src/lib.rs | 1 + codex-rs/worktree/src/manager.rs | 45 +++ 24 files changed, 632 insertions(+), 120 deletions(-) create mode 100644 CONTEXT.md create mode 100644 codex-rs/app-server-protocol/schema/json/v2/WorktreePruneParams.json create mode 100644 codex-rs/app-server-protocol/schema/json/v2/WorktreePruneResponse.json create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/WorktreePruneParams.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/WorktreePruneResponse.ts diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 000000000000..2f217e8db901 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,69 @@ +# Codex Worktrees + +This context describes how Codex names and reasons about Git-backed session destinations. The product exposes the familiar Git term while distinguishing those destinations from the broader notion of a session workspace. + +## Language + +**Workspace**: +The filesystem location where a Codex session runs. + +**Managed worktree**: +A Codex-owned workspace backed by a Git worktree. +_Avoid_: workspace when the Git-backed ownership matters + +**External worktree**: +A Git worktree visible to Codex but not owned or mutated by Codex. + +**Worktree origin**: +The current creator lineage of a managed worktree, such as CLI or App, retained for compatibility while clients converge on one app-server-backed implementation. + +**Worktree**: +The user-facing term for a managed worktree, matching the Codex App and developers' existing Git vocabulary. +_Avoid_: workspace when referring specifically to the product feature + +**Worktree management**: +Standalone creation, inspection, and removal of managed worktrees outside a running Codex session. + +**Worktree launch**: +Starting a Codex session in a named managed worktree, creating it when needed. + +**Worktree switching**: +Moving an active Codex session into a managed worktree from inside the TUI. + +## Relationships + +- A **managed worktree** is one kind of **workspace** +- An **external worktree** is a visible **workspace** that Codex does not own +- A **worktree** is the user-facing name for a **managed worktree** +- A **worktree origin** describes current provenance, not a permanent product subtype +- A **workspace** may exist without being a **managed worktree** +- **Worktree management** operates on **managed worktrees** whether or not a session is currently running in them +- **Worktree launch** may create or reuse a **managed worktree** before the session begins +- **Worktree switching** may create or reuse a **managed worktree** after the session has begun +- **Worktree management**, **worktree launch**, and **worktree switching** share the app-server-backed managed-worktree model + +## Example dialogue + +> **Dev:** "When a user chooses a **worktree**, are they choosing any **workspace**?" +> **Domain expert:** "No — they are choosing a Codex-managed Git-backed **workspace**." +> +> **Dev:** "Does a **worktree** only exist once a session starts there?" +> **Domain expert:** "No — **worktree management** lets users create and inspect it before a session enters it." +> +> **Dev:** "Why can `--worktree` create one instead of only selecting an existing one?" +> **Domain expert:** "Because **worktree launch** should be a one-step path into the named worktree." +> +> **Dev:** "Can `--force` remove an **external worktree**?" +> **Domain expert:** "No — force bypasses cleanliness checks, not ownership." +> +> **Dev:** "Are CLI and App worktrees different products?" +> **Domain expert:** "No — their **worktree origins** differ today while the App is still migrating toward the shared app-server implementation." +> +> **Dev:** "Can remote CLI commands manage worktrees outside a session?" +> **Domain expert:** "Yes — remote and local management both go through app-server." + +## Flagged ambiguities + +- "workspace" and "worktree" were being used interchangeably — resolved: **workspace** is broader; **worktree** names the Git-backed product feature. +- "force" could imply bypassing ownership — resolved: it only bypasses dirty-state protection for **managed worktrees**. +- "CLI worktree" and "App worktree" could sound like permanent subtypes — resolved: they are current **worktree origins**, not the long-term domain model. diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 8c2fa5141c19..4cc383cb78a3 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2180,6 +2180,7 @@ dependencies = [ "clap", "clap_complete", "codex-app-server", + "codex-app-server-client", "codex-app-server-protocol", "codex-app-server-test-client", "codex-arg0", @@ -2193,6 +2194,7 @@ dependencies = [ "codex-exec-server", "codex-execpolicy", "codex-features", + "codex-feedback", "codex-login", "codex-mcp", "codex-mcp-server", diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 6a49d3129c64..c2c57dab67c6 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -4621,6 +4621,10 @@ "WorktreeListParams": { "description": "Request the managed worktrees associated with the repository containing \\`cwd\\`.", "properties": { + "all": { + "description": "Include managed worktrees from every repository known to this app-server.", + "type": "boolean" + }, "cwd": { "description": "Repository-relative workspace cwd to inspect. Omitted uses app-server's effective cwd.", "type": [ @@ -4631,6 +4635,15 @@ }, "type": "object" }, + "WorktreePruneParams": { + "description": "Remove stale managed worktree metadata from app-server storage.", + "properties": { + "dryRun": { + "type": "boolean" + } + }, + "type": "object" + }, "WorktreeRemoveParams": { "description": "Remove a managed worktree in the repository containing \\`cwd\\`.", "properties": { @@ -5765,6 +5778,30 @@ "title": "Worktree/removeRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "worktree/prune" + ], + "title": "Worktree/pruneRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/WorktreePruneParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Worktree/pruneRequest", + "type": "object" + }, { "properties": { "id": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index b113e717e7b5..ae66b58cb4da 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -1290,6 +1290,30 @@ "title": "Worktree/removeRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "worktree/prune" + ], + "title": "Worktree/pruneRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/WorktreePruneParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Worktree/pruneRequest", + "type": "object" + }, { "properties": { "id": { @@ -18946,6 +18970,10 @@ "$schema": "http://json-schema.org/draft-07/schema#", "description": "Request the managed worktrees associated with the repository containing \\`cwd\\`.", "properties": { + "all": { + "description": "Include managed worktrees from every repository known to this app-server.", + "type": "boolean" + }, "cwd": { "description": "Repository-relative workspace cwd to inspect. Omitted uses app-server's effective cwd.", "type": [ @@ -18982,6 +19010,34 @@ ], "type": "string" }, + "WorktreePruneParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Remove stale managed worktree metadata from app-server storage.", + "properties": { + "dryRun": { + "type": "boolean" + } + }, + "title": "WorktreePruneParams", + "type": "object" + }, + "WorktreePruneResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Result returned by `worktree/prune`.", + "properties": { + "paths": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "paths" + ], + "title": "WorktreePruneResponse", + "type": "object" + }, "WorktreeRemoveParams": { "$schema": "http://json-schema.org/draft-07/schema#", "description": "Remove a managed worktree in the repository containing \\`cwd\\`.", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 84fe8f04f885..055f0c21d616 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -2049,6 +2049,30 @@ "title": "Worktree/removeRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "worktree/prune" + ], + "title": "Worktree/pruneRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/WorktreePruneParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Worktree/pruneRequest", + "type": "object" + }, { "properties": { "id": { @@ -16832,6 +16856,10 @@ "$schema": "http://json-schema.org/draft-07/schema#", "description": "Request the managed worktrees associated with the repository containing \\`cwd\\`.", "properties": { + "all": { + "description": "Include managed worktrees from every repository known to this app-server.", + "type": "boolean" + }, "cwd": { "description": "Repository-relative workspace cwd to inspect. Omitted uses app-server's effective cwd.", "type": [ @@ -16868,6 +16896,34 @@ ], "type": "string" }, + "WorktreePruneParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Remove stale managed worktree metadata from app-server storage.", + "properties": { + "dryRun": { + "type": "boolean" + } + }, + "title": "WorktreePruneParams", + "type": "object" + }, + "WorktreePruneResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Result returned by `worktree/prune`.", + "properties": { + "paths": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "paths" + ], + "title": "WorktreePruneResponse", + "type": "object" + }, "WorktreeRemoveParams": { "$schema": "http://json-schema.org/draft-07/schema#", "description": "Remove a managed worktree in the repository containing \\`cwd\\`.", diff --git a/codex-rs/app-server-protocol/schema/json/v2/WorktreeListParams.json b/codex-rs/app-server-protocol/schema/json/v2/WorktreeListParams.json index 50943d6908e0..129395c2f6b6 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/WorktreeListParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/WorktreeListParams.json @@ -2,6 +2,10 @@ "$schema": "http://json-schema.org/draft-07/schema#", "description": "Request the managed worktrees associated with the repository containing \\`cwd\\`.", "properties": { + "all": { + "description": "Include managed worktrees from every repository known to this app-server.", + "type": "boolean" + }, "cwd": { "description": "Repository-relative workspace cwd to inspect. Omitted uses app-server's effective cwd.", "type": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/WorktreePruneParams.json b/codex-rs/app-server-protocol/schema/json/v2/WorktreePruneParams.json new file mode 100644 index 000000000000..a8e78c340ce8 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/WorktreePruneParams.json @@ -0,0 +1,11 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Remove stale managed worktree metadata from app-server storage.", + "properties": { + "dryRun": { + "type": "boolean" + } + }, + "title": "WorktreePruneParams", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/WorktreePruneResponse.json b/codex-rs/app-server-protocol/schema/json/v2/WorktreePruneResponse.json new file mode 100644 index 000000000000..e2ccc7afffe3 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/WorktreePruneResponse.json @@ -0,0 +1,17 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Result returned by `worktree/prune`.", + "properties": { + "paths": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "paths" + ], + "title": "WorktreePruneResponse", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts index 9f0030d994fb..4a26f0f8415d 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts @@ -81,9 +81,10 @@ import type { WindowsSandboxSetupStartParams } from "./v2/WindowsSandboxSetupSta import type { WorktreeCreateParams } from "./v2/WorktreeCreateParams"; import type { WorktreeInspectSourceParams } from "./v2/WorktreeInspectSourceParams"; import type { WorktreeListParams } from "./v2/WorktreeListParams"; +import type { WorktreePruneParams } from "./v2/WorktreePruneParams"; import type { WorktreeRemoveParams } from "./v2/WorktreeRemoveParams"; /** * Request from the client to the server. */ -export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/approveGuardianDeniedAction", id: RequestId, params: ThreadApproveGuardianDeniedActionParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "thread/inject_items", id: RequestId, params: ThreadInjectItemsParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "hooks/list", id: RequestId, params: HooksListParams, } | { "method": "marketplace/add", id: RequestId, params: MarketplaceAddParams, } | { "method": "marketplace/remove", id: RequestId, params: MarketplaceRemoveParams, } | { "method": "marketplace/upgrade", id: RequestId, params: MarketplaceUpgradeParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "plugin/skill/read", id: RequestId, params: PluginSkillReadParams, } | { "method": "plugin/share/save", id: RequestId, params: PluginShareSaveParams, } | { "method": "plugin/share/updateTargets", id: RequestId, params: PluginShareUpdateTargetsParams, } | { "method": "plugin/share/list", id: RequestId, params: PluginShareListParams, } | { "method": "plugin/share/delete", id: RequestId, params: PluginShareDeleteParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "device/key/create", id: RequestId, params: DeviceKeyCreateParams, } | { "method": "device/key/public", id: RequestId, params: DeviceKeyPublicParams, } | { "method": "device/key/sign", id: RequestId, params: DeviceKeySignParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "fs/watch", id: RequestId, params: FsWatchParams, } | { "method": "fs/unwatch", id: RequestId, params: FsUnwatchParams, } | { "method": "worktree/list", id: RequestId, params: WorktreeListParams, } | { "method": "worktree/inspectSource", id: RequestId, params: WorktreeInspectSourceParams, } | { "method": "worktree/create", id: RequestId, params: WorktreeCreateParams, } | { "method": "worktree/remove", id: RequestId, params: WorktreeRemoveParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "modelProvider/capabilities/read", id: RequestId, params: ModelProviderCapabilitiesReadParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "experimentalFeature/enablement/set", id: RequestId, params: ExperimentalFeatureEnablementSetParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "mcpServer/resource/read", id: RequestId, params: McpResourceReadParams, } | { "method": "mcpServer/tool/call", id: RequestId, params: McpServerToolCallParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "windowsSandbox/readiness", id: RequestId, params: undefined, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "account/sendAddCreditsNudgeEmail", id: RequestId, params: SendAddCreditsNudgeEmailParams, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; +export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/approveGuardianDeniedAction", id: RequestId, params: ThreadApproveGuardianDeniedActionParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "thread/inject_items", id: RequestId, params: ThreadInjectItemsParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "hooks/list", id: RequestId, params: HooksListParams, } | { "method": "marketplace/add", id: RequestId, params: MarketplaceAddParams, } | { "method": "marketplace/remove", id: RequestId, params: MarketplaceRemoveParams, } | { "method": "marketplace/upgrade", id: RequestId, params: MarketplaceUpgradeParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "plugin/skill/read", id: RequestId, params: PluginSkillReadParams, } | { "method": "plugin/share/save", id: RequestId, params: PluginShareSaveParams, } | { "method": "plugin/share/updateTargets", id: RequestId, params: PluginShareUpdateTargetsParams, } | { "method": "plugin/share/list", id: RequestId, params: PluginShareListParams, } | { "method": "plugin/share/delete", id: RequestId, params: PluginShareDeleteParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "device/key/create", id: RequestId, params: DeviceKeyCreateParams, } | { "method": "device/key/public", id: RequestId, params: DeviceKeyPublicParams, } | { "method": "device/key/sign", id: RequestId, params: DeviceKeySignParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "fs/watch", id: RequestId, params: FsWatchParams, } | { "method": "fs/unwatch", id: RequestId, params: FsUnwatchParams, } | { "method": "worktree/list", id: RequestId, params: WorktreeListParams, } | { "method": "worktree/inspectSource", id: RequestId, params: WorktreeInspectSourceParams, } | { "method": "worktree/create", id: RequestId, params: WorktreeCreateParams, } | { "method": "worktree/remove", id: RequestId, params: WorktreeRemoveParams, } | { "method": "worktree/prune", id: RequestId, params: WorktreePruneParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "modelProvider/capabilities/read", id: RequestId, params: ModelProviderCapabilitiesReadParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "experimentalFeature/enablement/set", id: RequestId, params: ExperimentalFeatureEnablementSetParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "mcpServer/resource/read", id: RequestId, params: McpResourceReadParams, } | { "method": "mcpServer/tool/call", id: RequestId, params: McpServerToolCallParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "windowsSandbox/readiness", id: RequestId, params: undefined, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "account/sendAddCreditsNudgeEmail", id: RequestId, params: SendAddCreditsNudgeEmailParams, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/WorktreeListParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/WorktreeListParams.ts index c94e410ad3ab..a6da598903aa 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/WorktreeListParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/WorktreeListParams.ts @@ -9,4 +9,8 @@ export type WorktreeListParams = { /** * Repository-relative workspace cwd to inspect. Omitted uses app-server's effective cwd. */ -cwd?: string | null, }; +cwd?: string | null, +/** + * Include managed worktrees from every repository known to this app-server. + */ +all?: boolean, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/WorktreePruneParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/WorktreePruneParams.ts new file mode 100644 index 000000000000..9f98085b87d4 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/WorktreePruneParams.ts @@ -0,0 +1,8 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Remove stale managed worktree metadata from app-server storage. + */ +export type WorktreePruneParams = { dryRun?: boolean, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/WorktreePruneResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/WorktreePruneResponse.ts new file mode 100644 index 000000000000..e6deae3bfa5c --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/WorktreePruneResponse.ts @@ -0,0 +1,8 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Result returned by `worktree/prune`. + */ +export type WorktreePruneResponse = { paths: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index f894ec2e330c..d339f32ff8eb 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -462,6 +462,8 @@ export type { WorktreeInspectSourceResponse } from "./WorktreeInspectSourceRespo export type { WorktreeListParams } from "./WorktreeListParams"; export type { WorktreeListResponse } from "./WorktreeListResponse"; export type { WorktreeLocation } from "./WorktreeLocation"; +export type { WorktreePruneParams } from "./WorktreePruneParams"; +export type { WorktreePruneResponse } from "./WorktreePruneResponse"; export type { WorktreeRemoveParams } from "./WorktreeRemoveParams"; export type { WorktreeRemoveResponse } from "./WorktreeRemoveResponse"; export type { WorktreeSource } from "./WorktreeSource"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index ae008b496be3..3fb874da41c4 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -730,6 +730,11 @@ client_request_definitions! { serialization: global("worktree"), response: v2::WorktreeRemoveResponse, }, + WorktreePrune => "worktree/prune" { + params: v2::WorktreePruneParams, + serialization: global("worktree"), + response: v2::WorktreePruneResponse, + }, SkillsConfigWrite => "skills/config/write" { params: v2::SkillsConfigWriteParams, serialization: global("config"), diff --git a/codex-rs/app-server-protocol/src/protocol/v2/worktree.rs b/codex-rs/app-server-protocol/src/protocol/v2/worktree.rs index 35c802255a03..a92e50b5adfb 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/worktree.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/worktree.rs @@ -11,6 +11,9 @@ pub struct WorktreeListParams { /// Repository-relative workspace cwd to inspect. Omitted uses app-server's effective cwd. #[ts(optional = nullable)] pub cwd: Option, + /// Include managed worktrees from every repository known to this app-server. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub all: bool, } /// Managed worktrees returned by \`worktree/list\`. @@ -87,6 +90,23 @@ pub struct WorktreeRemoveResponse { pub deleted_branch: Option, } +/// Remove stale managed worktree metadata from app-server storage. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct WorktreePruneParams { + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub dry_run: bool, +} + +/// Result returned by `worktree/prune`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct WorktreePruneResponse { + pub paths: Vec, +} + /// Server-native representation of a managed worktree. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 421450c5e7ae..b938ce2f6b42 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -1152,10 +1152,11 @@ All filesystem paths in this section must be absolute. ### Worktrees -- `worktree/list` - list Codex-managed worktrees for the repository containing `cwd`; omitted `cwd` uses the app server's effective cwd. +- `worktree/list` - list Codex-managed worktrees for the repository containing `cwd`; omitted `cwd` uses the app server's effective cwd, and `all` returns worktrees from every repository. - `worktree/inspectSource` - inspect staged, unstaged, and untracked dirty state for the repository containing `cwd`. - `worktree/create` - create or reuse a managed worktree from `cwd`, returning the resolved worktree plus any transfer warnings. - `worktree/remove` - remove a managed worktree resolved from `cwd` and `nameOrPath`, with optional `force` and `deleteBranch` behavior. +- `worktree/prune` - remove stale managed-worktree storage entries, or report them without mutation when `dryRun` is set. ## Events diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 208983d65137..d604b74c5d98 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -961,6 +961,11 @@ impl MessageProcessor { .remove(params) .await .map(|response| Some(response.into())), + ClientRequest::WorktreePrune { params, .. } => self + .worktree_processor + .prune(params) + .await + .map(|response| Some(response.into())), ClientRequest::ModelProviderCapabilitiesRead { params: _, .. } => self .config_processor .model_provider_capabilities_read() diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index ad9845192cb2..753acc8272be 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -251,6 +251,8 @@ use codex_app_server_protocol::WorktreeInspectSourceResponse; use codex_app_server_protocol::WorktreeListParams; use codex_app_server_protocol::WorktreeListResponse; use codex_app_server_protocol::WorktreeLocation as ApiWorktreeLocation; +use codex_app_server_protocol::WorktreePruneParams; +use codex_app_server_protocol::WorktreePruneResponse; use codex_app_server_protocol::WorktreeRemoveParams; use codex_app_server_protocol::WorktreeRemoveResponse; use codex_app_server_protocol::WorktreeSource as ApiWorktreeSource; diff --git a/codex-rs/app-server/src/request_processors/worktree_processor.rs b/codex-rs/app-server/src/request_processors/worktree_processor.rs index 0e55356691ae..c912cb9c252c 100644 --- a/codex-rs/app-server/src/request_processors/worktree_processor.rs +++ b/codex-rs/app-server/src/request_processors/worktree_processor.rs @@ -26,11 +26,15 @@ impl WorktreeRequestProcessor { &self, params: WorktreeListParams, ) -> Result { - let cwd = self.resolve_cwd(params.cwd).await?; + let source_cwd = if params.all { + None + } else { + Some(self.resolve_cwd(params.cwd).await?) + }; let data = codex_worktree::list_worktrees(WorktreeListQuery { codex_home: self.config_manager.codex_home().to_path_buf(), - source_cwd: Some(cwd), - include_all_repos: false, + source_cwd, + include_all_repos: params.all, }) .map_err(map_worktree_error)? .into_iter() @@ -91,6 +95,31 @@ impl WorktreeRequestProcessor { }) } + pub(crate) async fn prune( + &self, + params: WorktreePruneParams, + ) -> Result { + let stale_paths = + codex_worktree::stale_managed_worktree_dirs(self.config_manager.codex_home()) + .map_err(map_worktree_error)?; + if !params.dry_run { + for path in &stale_paths { + std::fs::remove_dir_all(path).map_err(|err| { + map_worktree_error(anyhow::anyhow!( + "failed to remove stale worktree directory {}: {err}", + path.display() + )) + })?; + } + } + Ok(WorktreePruneResponse { + paths: stale_paths + .into_iter() + .map(|path| path.to_string_lossy().to_string()) + .collect(), + }) + } + async fn resolve_cwd(&self, cwd: Option) -> Result { match cwd { Some(cwd) => Ok(PathBuf::from(cwd)), diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index 89249654ea0c..3560a9cb1ebb 100644 --- a/codex-rs/cli/Cargo.toml +++ b/codex-rs/cli/Cargo.toml @@ -21,6 +21,7 @@ anyhow = { workspace = true } clap = { workspace = true, features = ["derive"] } clap_complete = { workspace = true } codex-app-server = { workspace = true } +codex-app-server-client = { workspace = true } codex-app-server-protocol = { workspace = true } codex-app-server-test-client = { workspace = true } codex-arg0 = { workspace = true } @@ -34,6 +35,7 @@ codex-core-plugins = { workspace = true } codex-exec = { workspace = true } codex-exec-server = { workspace = true } codex-execpolicy = { workspace = true } +codex-feedback = { workspace = true } codex-features = { workspace = true } codex-login = { workspace = true } codex-memories-write = { workspace = true } diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 954f4227a0a4..d65a4bb90d03 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -3,6 +3,26 @@ use clap::CommandFactory; use clap::Parser; use clap_complete::Shell; use clap_complete::generate; +use codex_app_server_client::AppServerClient; +use codex_app_server_client::DEFAULT_IN_PROCESS_CHANNEL_CAPACITY; +use codex_app_server_client::EnvironmentManager; +use codex_app_server_client::EnvironmentManagerArgs; +use codex_app_server_client::ExecServerRuntimePaths; +use codex_app_server_client::InProcessAppServerClient; +use codex_app_server_client::InProcessClientStartArgs; +use codex_app_server_client::RemoteAppServerClient; +use codex_app_server_client::RemoteAppServerConnectArgs; +use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::WorktreeCreateParams; +use codex_app_server_protocol::WorktreeCreateResponse; +use codex_app_server_protocol::WorktreeDirtyPolicy as ApiWorktreeDirtyPolicy; +use codex_app_server_protocol::WorktreeListParams; +use codex_app_server_protocol::WorktreeListResponse; +use codex_app_server_protocol::WorktreePruneParams; +use codex_app_server_protocol::WorktreePruneResponse; +use codex_app_server_protocol::WorktreeRemoveParams; +use codex_app_server_protocol::WorktreeRemoveResponse; use codex_arg0::Arg0DispatchPaths; use codex_arg0::arg0_dispatch_or_else; use codex_chatgpt::apply_command::ApplyCommand; @@ -36,17 +56,12 @@ use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_cli::CliConfigOverrides; use codex_utils_cli::SharedCliOptions; use codex_utils_cli::WorktreeDirtyCliArg; -use codex_worktree::DirtyPolicy; use codex_worktree::WorktreeInfo; -use codex_worktree::WorktreeListQuery; -use codex_worktree::WorktreeRemoveRequest; -use codex_worktree::WorktreeRequest; use codex_worktree::WorktreeResolution; use owo_colors::OwoColorize; -use std::fs; use std::io::IsTerminal; -use std::path::Path; use std::path::PathBuf; +use std::sync::Arc; use supports_color::Stream; #[cfg(any(target_os = "macos", target_os = "windows"))] @@ -760,10 +775,11 @@ async fn run_debug_app_server_command(cmd: DebugAppServerCommand) -> anyhow::Res } } -fn resolve_worktree_options_for_shared_cli( +async fn resolve_worktree_options_for_shared_cli( shared: &mut SharedCliOptions, remote: Option<&str>, remote_auth_token_env: Option<&str>, + arg0_paths: Arg0DispatchPaths, ) -> anyhow::Result> { let Some(branch) = shared.worktree.take() else { return Ok(None); @@ -773,15 +789,33 @@ fn resolve_worktree_options_for_shared_cli( anyhow::bail!("--worktree is not supported with remote app-server sessions yet"); } - let codex_home = find_codex_home()?.to_path_buf(); let source_cwd = shared.cwd.clone().unwrap_or(std::env::current_dir()?); - let resolution = codex_worktree::ensure_worktree(WorktreeRequest { - codex_home, - source_cwd, - branch, - base_ref: shared.worktree_base.take(), - dirty_policy: dirty_policy_from_cli(shared.worktree_dirty), - })?; + let client = worktree_app_server_client( + /*remote*/ None, /*remote_auth_token_env*/ None, arg0_paths, + ) + .await?; + let response: WorktreeCreateResponse = client + .request_typed(ClientRequest::WorktreeCreate { + request_id: RequestId::Integer(1), + params: WorktreeCreateParams { + cwd: Some(source_cwd.to_string_lossy().to_string()), + branch, + base_ref: shared.worktree_base.take(), + dirty_policy: api_dirty_policy_from_cli(shared.worktree_dirty), + }, + }) + .await?; + let resolution = WorktreeResolution { + reused: response.reused, + info: worktree_info_from_api(response.info)?, + warnings: response + .warnings + .into_iter() + .map(|warning| codex_worktree::WorktreeWarning { + message: warning.message, + }) + .collect(), + }; shared.cwd = Some(resolution.info.workspace_cwd.clone()); #[allow(clippy::print_stderr)] @@ -792,70 +826,79 @@ fn resolve_worktree_options_for_shared_cli( Ok(Some(resolution)) } -fn dirty_policy_from_cli(arg: WorktreeDirtyCliArg) -> DirtyPolicy { - match arg { - WorktreeDirtyCliArg::Fail => DirtyPolicy::Fail, - WorktreeDirtyCliArg::Ignore => DirtyPolicy::Ignore, - WorktreeDirtyCliArg::CopyTracked => DirtyPolicy::CopyTracked, - WorktreeDirtyCliArg::CopyAll => DirtyPolicy::CopyAll, - WorktreeDirtyCliArg::MoveTracked => DirtyPolicy::MoveTracked, - WorktreeDirtyCliArg::MoveAll => DirtyPolicy::MoveAll, - } -} - -fn run_worktree_command(cli: WorktreeCli) -> anyhow::Result<()> { - let codex_home = find_codex_home()?.to_path_buf(); +async fn run_worktree_command( + cli: WorktreeCli, + remote: Option, + remote_auth_token_env: Option, + arg0_paths: Arg0DispatchPaths, +) -> anyhow::Result<()> { + let is_remote = remote.is_some(); + let client = worktree_app_server_client(remote, remote_auth_token_env, arg0_paths).await?; + let cwd = (!is_remote) + .then(std::env::current_dir) + .transpose()? + .map(|cwd| cwd.to_string_lossy().to_string()); + let mut next_request_id = 1_i64; match cli.subcommand { WorktreeSubcommand::Create(command) => { - let resolution = codex_worktree::ensure_worktree(WorktreeRequest { - codex_home, - source_cwd: std::env::current_dir()?, - branch: command.branch, - base_ref: command.base_ref, - dirty_policy: dirty_policy_from_cli(command.dirty), - })?; + let resolution: WorktreeCreateResponse = client + .request_typed(ClientRequest::WorktreeCreate { + request_id: next_cli_request_id(&mut next_request_id), + params: WorktreeCreateParams { + cwd: cwd.clone(), + branch: command.branch, + base_ref: command.base_ref, + dirty_policy: api_dirty_policy_from_cli(command.dirty), + }, + }) + .await?; for warning in &resolution.warnings { eprintln!("warning: {}", warning.message); } - println!("{}", resolution.info.workspace_cwd.display()); + println!("{}", resolution.info.workspace_cwd); } WorktreeSubcommand::List(command) => { - let source_cwd = if command.all { - None - } else { - Some(std::env::current_dir()?) - }; - let entries = codex_worktree::list_worktrees(WorktreeListQuery { - codex_home, - source_cwd, - include_all_repos: command.all, - })?; + let entries = + cli_list_worktrees(&client, cwd.clone(), command.all, &mut next_request_id).await?; print_worktree_list(entries, command.json)?; } WorktreeSubcommand::Path(command) => { - let entries = codex_worktree::list_worktrees(WorktreeListQuery { - codex_home, - source_cwd: Some(std::env::current_dir()?), - include_all_repos: false, - })?; + let entries = + cli_list_worktrees(&client, cwd.clone(), false, &mut next_request_id).await?; let entry = find_named_worktree(entries, &command.name)?; println!("{}", entry.workspace_cwd.display()); } WorktreeSubcommand::Remove(command) => { - let result = codex_worktree::remove_worktree(WorktreeRemoveRequest { - codex_home, - source_cwd: Some(std::env::current_dir()?), - name_or_path: command.name_or_path, - force: command.force, - delete_branch: command.delete_branch, - })?; - println!("removed {}", result.removed_path.display()); + let result: WorktreeRemoveResponse = client + .request_typed(ClientRequest::WorktreeRemove { + request_id: next_cli_request_id(&mut next_request_id), + params: WorktreeRemoveParams { + cwd, + name_or_path: command.name_or_path, + force: command.force, + delete_branch: command.delete_branch, + }, + }) + .await?; + println!("removed {}", result.removed_path); if let Some(branch) = result.deleted_branch { println!("deleted branch {branch}"); } } WorktreeSubcommand::Prune(command) => { - let stale_paths = stale_managed_worktree_dirs(&codex_home)?; + let response: WorktreePruneResponse = client + .request_typed(ClientRequest::WorktreePrune { + request_id: next_cli_request_id(&mut next_request_id), + params: WorktreePruneParams { + dry_run: command.dry_run || command.json, + }, + }) + .await?; + let stale_paths = response + .paths + .into_iter() + .map(PathBuf::from) + .collect::>(); if command.json { println!("{}", serde_json::to_string_pretty(&stale_paths)?); } else if stale_paths.is_empty() { @@ -865,7 +908,6 @@ fn run_worktree_command(cli: WorktreeCli) -> anyhow::Result<()> { if command.dry_run { println!("would remove {}", path.display()); } else { - fs::remove_dir_all(path)?; println!("removed {}", path.display()); } } @@ -875,6 +917,133 @@ fn run_worktree_command(cli: WorktreeCli) -> anyhow::Result<()> { Ok(()) } +async fn cli_list_worktrees( + client: &AppServerClient, + cwd: Option, + all: bool, + next_request_id: &mut i64, +) -> anyhow::Result> { + let response: WorktreeListResponse = client + .request_typed(ClientRequest::WorktreeList { + request_id: next_cli_request_id(next_request_id), + params: WorktreeListParams { cwd, all }, + }) + .await?; + response + .data + .into_iter() + .map(worktree_info_from_api) + .collect() +} + +fn next_cli_request_id(next: &mut i64) -> RequestId { + let request_id = RequestId::Integer(*next); + *next += 1; + request_id +} + +fn api_dirty_policy_from_cli(value: WorktreeDirtyCliArg) -> ApiWorktreeDirtyPolicy { + match value { + WorktreeDirtyCliArg::Fail => ApiWorktreeDirtyPolicy::Fail, + WorktreeDirtyCliArg::Ignore => ApiWorktreeDirtyPolicy::Ignore, + WorktreeDirtyCliArg::CopyTracked => ApiWorktreeDirtyPolicy::CopyTracked, + WorktreeDirtyCliArg::CopyAll => ApiWorktreeDirtyPolicy::CopyAll, + WorktreeDirtyCliArg::MoveTracked => ApiWorktreeDirtyPolicy::MoveTracked, + WorktreeDirtyCliArg::MoveAll => ApiWorktreeDirtyPolicy::MoveAll, + } +} + +async fn worktree_app_server_client( + remote: Option, + remote_auth_token_env: Option, + arg0_paths: Arg0DispatchPaths, +) -> anyhow::Result { + if let Some(remote) = remote { + let websocket_url = + codex_tui::normalize_remote_addr(&remote).map_err(|err| anyhow::anyhow!("{err}"))?; + let auth_token = remote_auth_token_env + .map(|name| std::env::var(&name)) + .transpose()?; + return Ok(AppServerClient::Remote( + RemoteAppServerClient::connect(RemoteAppServerConnectArgs { + websocket_url, + auth_token, + client_name: "codex-cli-worktree".to_string(), + client_version: env!("CARGO_PKG_VERSION").to_string(), + experimental_api: true, + opt_out_notification_methods: Vec::new(), + channel_capacity: DEFAULT_IN_PROCESS_CHANNEL_CAPACITY, + }) + .await?, + )); + } + + let config = Config::load_with_cli_overrides(Vec::new()).await?; + let local_runtime_paths = ExecServerRuntimePaths::from_optional_paths( + arg0_paths.codex_self_exe.clone(), + arg0_paths.codex_linux_sandbox_exe.clone(), + )?; + let config_warnings = config + .startup_warnings + .iter() + .map( + |warning| codex_app_server_protocol::ConfigWarningNotification { + summary: warning.clone(), + details: None, + path: None, + range: None, + }, + ) + .collect(); + Ok(AppServerClient::InProcess( + InProcessAppServerClient::start(InProcessClientStartArgs { + arg0_paths, + config: Arc::new(config), + cli_overrides: Vec::new(), + loader_overrides: codex_config::LoaderOverrides::default(), + cloud_requirements: codex_config::CloudRequirementsLoader::default(), + feedback: codex_feedback::CodexFeedback::new(), + log_db: None, + state_db: None, + environment_manager: Arc::new( + EnvironmentManager::new(EnvironmentManagerArgs::new(local_runtime_paths)).await, + ), + config_warnings, + session_source: codex_protocol::protocol::SessionSource::Cli, + enable_codex_api_key_env: false, + client_name: "codex-cli-worktree".to_string(), + client_version: env!("CARGO_PKG_VERSION").to_string(), + experimental_api: true, + opt_out_notification_methods: Vec::new(), + channel_capacity: DEFAULT_IN_PROCESS_CHANNEL_CAPACITY, + }) + .await?, + )) +} + +fn worktree_info_from_api( + info: codex_app_server_protocol::WorktreeInfo, +) -> anyhow::Result { + Ok(WorktreeInfo { + id: info.id, + name: info.name, + slug: info.slug, + source: serde_json::from_value(serde_json::to_value(info.source)?)?, + location: serde_json::from_value(serde_json::to_value(info.location)?)?, + repo_name: info.repo_name, + repo_root: PathBuf::from(info.repo_root), + common_git_dir: PathBuf::from(info.common_git_dir), + worktree_git_root: PathBuf::from(info.worktree_git_root), + workspace_cwd: PathBuf::from(info.workspace_cwd), + original_relative_cwd: PathBuf::from(info.original_relative_cwd), + branch: info.branch, + head: info.head, + owner_thread_id: info.owner_thread_id, + metadata_path: PathBuf::from(info.metadata_path), + dirty: serde_json::from_value(serde_json::to_value(info.dirty)?)?, + }) +} + fn print_worktree_list(entries: Vec, json: bool) -> anyhow::Result<()> { if json { println!("{}", serde_json::to_string_pretty(&entries)?); @@ -959,52 +1128,6 @@ fn find_named_worktree(entries: Vec, name: &str) -> anyhow::Result } } -fn stale_managed_worktree_dirs(codex_home: &Path) -> anyhow::Result> { - let root = codex_worktree::codex_worktrees_root(codex_home); - if !root.exists() { - return Ok(Vec::new()); - } - - let mut stale = Vec::new(); - for repo_dir in fs::read_dir(&root)? { - let repo_dir = repo_dir?; - if !repo_dir.file_type()?.is_dir() { - continue; - } - for slug_dir in fs::read_dir(repo_dir.path())? { - let slug_dir = slug_dir?; - if !slug_dir.file_type()?.is_dir() { - continue; - } - let mut has_repo_dir = false; - for repo_root in fs::read_dir(slug_dir.path())? { - let repo_root = repo_root?; - if !repo_root.file_type()?.is_dir() { - continue; - } - has_repo_dir = true; - if !repo_root.path().join(".git").exists() || !git_root_is_valid(&repo_root.path()) - { - stale.push(repo_root.path()); - } - } - if !has_repo_dir { - stale.push(slug_dir.path()); - } - } - } - stale.sort(); - Ok(stale) -} - -fn git_root_is_valid(path: &Path) -> bool { - std::process::Command::new("git") - .args(["rev-parse", "--show-toplevel"]) - .current_dir(path) - .output() - .is_ok_and(|output| output.status.success()) -} - #[derive(Debug, Default, Parser, Clone)] struct FeatureToggles { /// Enable a feature (repeatable). Equivalent to `-c features.=true`. @@ -1135,7 +1258,9 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { &mut exec_cli.shared, /*remote*/ None, /*remote_auth_token_env*/ None, - )?; + arg0_paths.clone(), + ) + .await?; prepend_config_flags( &mut exec_cli.config_overrides, root_config_overrides.clone(), @@ -1324,12 +1449,13 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { handle_app_exit(exit_info)?; } Some(Subcommand::Worktree(worktree_cli)) => { - reject_remote_mode_for_subcommand( - root_remote.as_deref(), - root_remote_auth_token_env.as_deref(), - "worktree", - )?; - run_worktree_command(worktree_cli)?; + run_worktree_command( + worktree_cli, + root_remote.clone(), + root_remote_auth_token_env.clone(), + arg0_paths.clone(), + ) + .await?; } Some(Subcommand::Login(mut login_cli)) => { reject_remote_mode_for_subcommand( diff --git a/codex-rs/tui/src/app/worktree.rs b/codex-rs/tui/src/app/worktree.rs index 191a359c174c..3efcf26fcc63 100644 --- a/codex-rs/tui/src/app/worktree.rs +++ b/codex-rs/tui/src/app/worktree.rs @@ -330,6 +330,7 @@ impl App { request_id: worktree_request_id("worktree-list"), params: WorktreeListParams { cwd: Some(self.session_workspace_cwd(app_server).display().to_string()), + all: false, }, }) .await?; diff --git a/codex-rs/worktree/src/lib.rs b/codex-rs/worktree/src/lib.rs index 8c863028a0a9..6accd4193075 100644 --- a/codex-rs/worktree/src/lib.rs +++ b/codex-rs/worktree/src/lib.rs @@ -16,6 +16,7 @@ pub use manager::ensure_worktree; pub use manager::list_worktrees; pub use manager::remove_worktree; pub use manager::resolve_worktree; +pub use manager::stale_managed_worktree_dirs; pub use metadata::WorktreeMetadata; pub use metadata::WorktreeThreadMetadata; pub use metadata::bind_thread; diff --git a/codex-rs/worktree/src/manager.rs b/codex-rs/worktree/src/manager.rs index 231bf55e14dc..9fbdc2e9b046 100644 --- a/codex-rs/worktree/src/manager.rs +++ b/codex-rs/worktree/src/manager.rs @@ -309,6 +309,51 @@ fn is_git_root(path: &Path) -> bool { path.join(".git").exists() } +pub fn stale_managed_worktree_dirs(codex_home: &Path) -> Result> { + let root = paths::codex_worktrees_root(codex_home); + if !root.exists() { + return Ok(Vec::new()); + } + let mut stale = Vec::new(); + for repo_dir in fs::read_dir(&root)? { + let repo_dir = repo_dir?; + if !repo_dir.file_type()?.is_dir() { + continue; + } + for slug_dir in fs::read_dir(repo_dir.path())? { + let slug_dir = slug_dir?; + if !slug_dir.file_type()?.is_dir() { + continue; + } + let mut has_repo_dir = false; + for repo_root in fs::read_dir(slug_dir.path())? { + let repo_root = repo_root?; + if !repo_root.file_type()?.is_dir() { + continue; + } + has_repo_dir = true; + if !repo_root.path().join(".git").exists() || !git_root_is_valid(&repo_root.path()) + { + stale.push(repo_root.path()); + } + } + if !has_repo_dir { + stale.push(slug_dir.path()); + } + } + } + stale.sort(); + Ok(stale) +} + +fn git_root_is_valid(path: &Path) -> bool { + std::process::Command::new("git") + .args(["rev-parse", "--show-toplevel"]) + .current_dir(path) + .output() + .is_ok_and(|output| output.status.success()) +} + fn worktree_matches_repo(info: &WorktreeInfo, repo: &SourceRepo) -> bool { info.id == repo.id || paths_match(&info.common_git_dir, &repo.common_git_dir) } From 3e1a5e727585c5bf1fda0b87ddfb76a58459df66 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Sun, 10 May 2026 12:53:01 -0300 Subject: [PATCH 12/12] refactor(worktree): simplify app-server client boundaries --- codex-rs/Cargo.lock | 1 - .../request_processors/worktree_processor.rs | 18 +- codex-rs/cli/src/main.rs | 92 +++---- codex-rs/exec/Cargo.toml | 1 - codex-rs/exec/src/lib.rs | 17 -- codex-rs/tui/src/app/event_dispatch.rs | 21 +- codex-rs/tui/src/app/worktree.rs | 241 +++++++----------- codex-rs/tui/src/app_event.rs | 17 +- codex-rs/tui/src/worktree.rs | 71 ++++-- codex-rs/worktree/src/lib.rs | 1 + codex-rs/worktree/src/manager.rs | 41 +++ 11 files changed, 243 insertions(+), 278 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 4cc383cb78a3..27a0cd000e16 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2682,7 +2682,6 @@ dependencies = [ "codex-utils-cargo-bin", "codex-utils-cli", "codex-utils-oss", - "codex-worktree", "core_test_support", "libc", "opentelemetry", diff --git a/codex-rs/app-server/src/request_processors/worktree_processor.rs b/codex-rs/app-server/src/request_processors/worktree_processor.rs index c912cb9c252c..fea8e4581596 100644 --- a/codex-rs/app-server/src/request_processors/worktree_processor.rs +++ b/codex-rs/app-server/src/request_processors/worktree_processor.rs @@ -99,19 +99,11 @@ impl WorktreeRequestProcessor { &self, params: WorktreePruneParams, ) -> Result { - let stale_paths = - codex_worktree::stale_managed_worktree_dirs(self.config_manager.codex_home()) - .map_err(map_worktree_error)?; - if !params.dry_run { - for path in &stale_paths { - std::fs::remove_dir_all(path).map_err(|err| { - map_worktree_error(anyhow::anyhow!( - "failed to remove stale worktree directory {}: {err}", - path.display() - )) - })?; - } - } + let stale_paths = codex_worktree::prune_stale_managed_worktree_dirs( + self.config_manager.codex_home(), + params.dry_run, + ) + .map_err(map_worktree_error)?; Ok(WorktreePruneResponse { paths: stale_paths .into_iter() diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index d65a4bb90d03..2e637b7daa67 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -17,12 +17,15 @@ use codex_app_server_protocol::RequestId; use codex_app_server_protocol::WorktreeCreateParams; use codex_app_server_protocol::WorktreeCreateResponse; use codex_app_server_protocol::WorktreeDirtyPolicy as ApiWorktreeDirtyPolicy; +use codex_app_server_protocol::WorktreeDirtyState; +use codex_app_server_protocol::WorktreeInfo; use codex_app_server_protocol::WorktreeListParams; use codex_app_server_protocol::WorktreeListResponse; use codex_app_server_protocol::WorktreePruneParams; use codex_app_server_protocol::WorktreePruneResponse; use codex_app_server_protocol::WorktreeRemoveParams; use codex_app_server_protocol::WorktreeRemoveResponse; +use codex_app_server_protocol::WorktreeSource; use codex_arg0::Arg0DispatchPaths; use codex_arg0::arg0_dispatch_or_else; use codex_chatgpt::apply_command::ApplyCommand; @@ -56,8 +59,6 @@ use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_cli::CliConfigOverrides; use codex_utils_cli::SharedCliOptions; use codex_utils_cli::WorktreeDirtyCliArg; -use codex_worktree::WorktreeInfo; -use codex_worktree::WorktreeResolution; use owo_colors::OwoColorize; use std::io::IsTerminal; use std::path::PathBuf; @@ -780,9 +781,9 @@ async fn resolve_worktree_options_for_shared_cli( remote: Option<&str>, remote_auth_token_env: Option<&str>, arg0_paths: Arg0DispatchPaths, -) -> anyhow::Result> { +) -> anyhow::Result<()> { let Some(branch) = shared.worktree.take() else { - return Ok(None); + return Ok(()); }; if remote.is_some() || remote_auth_token_env.is_some() { @@ -805,25 +806,14 @@ async fn resolve_worktree_options_for_shared_cli( }, }) .await?; - let resolution = WorktreeResolution { - reused: response.reused, - info: worktree_info_from_api(response.info)?, - warnings: response - .warnings - .into_iter() - .map(|warning| codex_worktree::WorktreeWarning { - message: warning.message, - }) - .collect(), - }; - shared.cwd = Some(resolution.info.workspace_cwd.clone()); + shared.cwd = Some(PathBuf::from(response.info.workspace_cwd)); #[allow(clippy::print_stderr)] - for warning in &resolution.warnings { + for warning in &response.warnings { eprintln!("warning: {}", warning.message); } - Ok(Some(resolution)) + Ok(()) } async fn run_worktree_command( @@ -863,10 +853,15 @@ async fn run_worktree_command( print_worktree_list(entries, command.json)?; } WorktreeSubcommand::Path(command) => { - let entries = - cli_list_worktrees(&client, cwd.clone(), false, &mut next_request_id).await?; + let entries = cli_list_worktrees( + &client, + cwd.clone(), + /*all*/ false, + &mut next_request_id, + ) + .await?; let entry = find_named_worktree(entries, &command.name)?; - println!("{}", entry.workspace_cwd.display()); + println!("{}", entry.workspace_cwd); } WorktreeSubcommand::Remove(command) => { let result: WorktreeRemoveResponse = client @@ -894,11 +889,7 @@ async fn run_worktree_command( }, }) .await?; - let stale_paths = response - .paths - .into_iter() - .map(PathBuf::from) - .collect::>(); + let stale_paths = response.paths; if command.json { println!("{}", serde_json::to_string_pretty(&stale_paths)?); } else if stale_paths.is_empty() { @@ -906,9 +897,9 @@ async fn run_worktree_command( } else { for path in &stale_paths { if command.dry_run { - println!("would remove {}", path.display()); + println!("would remove {path}"); } else { - println!("removed {}", path.display()); + println!("removed {path}"); } } } @@ -929,11 +920,7 @@ async fn cli_list_worktrees( params: WorktreeListParams { cwd, all }, }) .await?; - response - .data - .into_iter() - .map(worktree_info_from_api) - .collect() + Ok(response.data) } fn next_cli_request_id(next: &mut i64) -> RequestId { @@ -1021,29 +1008,6 @@ async fn worktree_app_server_client( )) } -fn worktree_info_from_api( - info: codex_app_server_protocol::WorktreeInfo, -) -> anyhow::Result { - Ok(WorktreeInfo { - id: info.id, - name: info.name, - slug: info.slug, - source: serde_json::from_value(serde_json::to_value(info.source)?)?, - location: serde_json::from_value(serde_json::to_value(info.location)?)?, - repo_name: info.repo_name, - repo_root: PathBuf::from(info.repo_root), - common_git_dir: PathBuf::from(info.common_git_dir), - worktree_git_root: PathBuf::from(info.worktree_git_root), - workspace_cwd: PathBuf::from(info.workspace_cwd), - original_relative_cwd: PathBuf::from(info.original_relative_cwd), - branch: info.branch, - head: info.head, - owner_thread_id: info.owner_thread_id, - metadata_path: PathBuf::from(info.metadata_path), - dirty: serde_json::from_value(serde_json::to_value(info.dirty)?)?, - }) -} - fn print_worktree_list(entries: Vec, json: bool) -> anyhow::Result<()> { if json { println!("{}", serde_json::to_string_pretty(&entries)?); @@ -1052,7 +1016,7 @@ fn print_worktree_list(entries: Vec, json: bool) -> anyhow::Result let mut rows = Vec::new(); for entry in &entries { - let status = if entry.dirty.is_dirty() { + let status = if dirty_state_is_dirty(&entry.dirty) { "dirty" } else { "clean" @@ -1066,7 +1030,7 @@ fn print_worktree_list(entries: Vec, json: bool) -> anyhow::Result .as_deref() .unwrap_or("none") .to_string(), - entry.workspace_cwd.display().to_string(), + entry.workspace_cwd.clone(), ]); } let headers = ["BRANCH", "STATUS", "SOURCE", "THREAD", "PATH"]; @@ -1107,13 +1071,17 @@ fn print_worktree_list(entries: Vec, json: bool) -> anyhow::Result fn worktree_source_label(entry: &WorktreeInfo) -> &'static str { match entry.source { - codex_worktree::WorktreeSource::Cli => "cli", - codex_worktree::WorktreeSource::App => "app", - codex_worktree::WorktreeSource::Legacy => "legacy", - codex_worktree::WorktreeSource::Git => "git", + WorktreeSource::Cli => "cli", + WorktreeSource::App => "app", + WorktreeSource::Legacy => "legacy", + WorktreeSource::Git => "git", } } +fn dirty_state_is_dirty(dirty: &WorktreeDirtyState) -> bool { + dirty.has_staged_changes || dirty.has_unstaged_changes || dirty.has_untracked_files +} + fn find_named_worktree(entries: Vec, name: &str) -> anyhow::Result { let matches = entries .into_iter() diff --git a/codex-rs/exec/Cargo.toml b/codex-rs/exec/Cargo.toml index 94d77a2fc20a..632e47940476 100644 --- a/codex-rs/exec/Cargo.toml +++ b/codex-rs/exec/Cargo.toml @@ -37,7 +37,6 @@ codex-otel = { workspace = true } codex-protocol = { workspace = true } codex-utils-absolute-path = { workspace = true } codex-utils-cli = { workspace = true } -codex-worktree = { workspace = true } codex-utils-oss = { workspace = true } owo-colors = { workspace = true } serde = { workspace = true, features = ["derive"] } diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index db54d1be880b..56088f68571d 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -696,7 +696,6 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { ) .await .map_err(anyhow::Error::msg)?; - bind_worktree_thread_best_effort(&config, response.cwd.as_path(), &response.thread.id); let session_configured = session_configured_from_thread_resume_response(&response, &config) .map_err(anyhow::Error::msg)?; @@ -712,7 +711,6 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { ) .await .map_err(anyhow::Error::msg)?; - bind_worktree_thread_best_effort(&config, response.cwd.as_path(), &response.thread.id); let session_configured = session_configured_from_thread_start_response(&response, &config) .map_err(anyhow::Error::msg)?; @@ -729,7 +727,6 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { ) .await .map_err(anyhow::Error::msg)?; - bind_worktree_thread_best_effort(&config, response.cwd.as_path(), &response.thread.id); let session_configured = session_configured_from_thread_start_response(&response, &config) .map_err(anyhow::Error::msg)?; (session_configured.thread_id, session_configured) @@ -1111,20 +1108,6 @@ fn session_configured_from_thread_resume_response( ) } -fn bind_worktree_thread_best_effort(config: &Config, cwd: &Path, thread_id: &str) { - match codex_worktree::resolve_worktree(config.codex_home.as_path(), cwd) { - Ok(Some(_)) => { - if let Err(err) = codex_worktree::bind_thread(cwd, thread_id) { - tracing::warn!(?err, "failed to bind managed worktree to thread"); - } - } - Ok(None) => {} - Err(err) => { - tracing::warn!(?err, "failed to resolve managed worktree metadata"); - } - } -} - fn review_target_to_api(target: ReviewTarget) -> ApiReviewTarget { match target { ReviewTarget::UncommittedChanges => ApiReviewTarget::UncommittedChanges, diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 88f8bea20e61..a68d0fa81f44 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -213,15 +213,20 @@ impl App { tui.frame_requester().schedule_frame(); } AppEvent::SwitchToWorktree { target } => { - self.begin_switch_to_worktree_target(tui, target); + self.begin_switch_to_worktree_target(tui, app_server, target) + .await; + tui.frame_requester().schedule_frame(); + } + AppEvent::SwitchToWorktreeInfo { info } => { + self.begin_switch_to_worktree_info(tui, info); tui.frame_requester().schedule_frame(); } AppEvent::CurrentWorktreeSelected { target } => { self.current_worktree_selected(target); tui.frame_requester().schedule_frame(); } - AppEvent::SwitchToWorktreeAfterLoading { target } => { - self.switch_to_worktree_target_after_loading(tui, app_server, target) + AppEvent::SwitchToWorktreeAfterLoading { info } => { + self.switch_to_worktree_info_after_loading(tui, app_server, info) .await; tui.frame_requester().schedule_frame(); } @@ -233,7 +238,15 @@ impl App { result, } => { self.on_worktree_session_ready( - tui, app_server, info, config, forked, warnings, result, + tui, + app_server, + super::worktree::WorktreeSessionReadyArgs { + info, + config, + forked, + warnings, + result, + }, ) .await; tui.frame_requester().schedule_frame(); diff --git a/codex-rs/tui/src/app/worktree.rs b/codex-rs/tui/src/app/worktree.rs index 3efcf26fcc63..8c5e456d19d1 100644 --- a/codex-rs/tui/src/app/worktree.rs +++ b/codex-rs/tui/src/app/worktree.rs @@ -4,25 +4,17 @@ use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::WorktreeCreateParams; use codex_app_server_protocol::WorktreeCreateResponse; -use codex_app_server_protocol::WorktreeDirtyPolicy as ApiWorktreeDirtyPolicy; -use codex_app_server_protocol::WorktreeDirtyState as ApiWorktreeDirtyState; -use codex_app_server_protocol::WorktreeInfo as ApiWorktreeInfo; +use codex_app_server_protocol::WorktreeDirtyPolicy; +use codex_app_server_protocol::WorktreeDirtyState; +use codex_app_server_protocol::WorktreeInfo; use codex_app_server_protocol::WorktreeInspectSourceParams; use codex_app_server_protocol::WorktreeInspectSourceResponse; use codex_app_server_protocol::WorktreeListParams; use codex_app_server_protocol::WorktreeListResponse; -use codex_app_server_protocol::WorktreeLocation as ApiWorktreeLocation; use codex_app_server_protocol::WorktreeRemoveParams; use codex_app_server_protocol::WorktreeRemoveResponse; -use codex_app_server_protocol::WorktreeSource as ApiWorktreeSource; +use codex_app_server_protocol::WorktreeSource; use codex_protocol::ThreadId; -use codex_worktree::DirtyPolicy; -use codex_worktree::WorktreeInfo; -use codex_worktree::WorktreeLocation; -use codex_worktree::WorktreeRequest; -use codex_worktree::WorktreeResolution; -use codex_worktree::WorktreeSource; -use codex_worktree::WorktreeWarning; use std::path::Path; use std::path::PathBuf; use std::time::Duration; @@ -45,6 +37,14 @@ enum WorktreeSessionTransition { Started, } +pub(super) struct WorktreeSessionReadyArgs { + pub(super) info: WorktreeInfo, + pub(super) config: Config, + pub(super) forked: bool, + pub(super) warnings: Vec, + pub(super) result: Result, +} + impl WorktreeSessionTransition { fn message_prefix(self) -> &'static str { match self { @@ -133,17 +133,17 @@ impl App { app_server: &AppServerSession, branch: String, base_ref: Option, - dirty_policy: Option, + dirty_policy: Option, ) { let dirty_policy = match dirty_policy { Some(policy) => policy, None => match self.source_worktree_dirty_state(app_server).await { - Ok(state) if state.is_dirty() => { + Ok(state) if dirty_state_is_dirty(&state) => { let params = crate::worktree::dirty_policy_prompt_params(branch, base_ref); self.chat_widget.show_selection_view(params); return; } - Ok(_) => DirtyPolicy::Fail, + Ok(_) => WorktreeDirtyPolicy::Fail, Err(err) => { self.chat_widget .add_error_message(format!("Failed to inspect source checkout: {err}")); @@ -155,13 +155,10 @@ impl App { self.show_worktree_creating_view(tui, branch.clone()); self.spawn_worktree_create_request( app_server, - WorktreeRequest { - codex_home: self.config.codex_home.to_path_buf(), - source_cwd: self.session_workspace_cwd(app_server).to_path_buf(), - branch, - base_ref, - dirty_policy, - }, + self.session_workspace_cwd(app_server).to_path_buf(), + branch, + base_ref, + dirty_policy, ); } @@ -170,29 +167,29 @@ impl App { tui: &mut tui::Tui, app_server: &mut AppServerSession, cwd: PathBuf, - result: Result, + result: Result, ) { if cwd.as_path() != self.session_workspace_cwd(app_server) { return; } - let resolution = match result { - Ok(resolution) => resolution, + let response = match result { + Ok(response) => response, Err(err) => { self.show_worktree_error("Failed to create worktree.".to_string(), err); return; } }; - let target = resolution + let target = response .info .branch .clone() - .unwrap_or_else(|| resolution.info.name.clone()); + .unwrap_or_else(|| response.info.name.clone()); self.show_worktree_switching_view(tui, target); self.switch_to_worktree_info( tui, app_server, - resolution.info, - resolution + response.info, + response .warnings .into_iter() .map(|warning| warning.message) @@ -201,20 +198,10 @@ impl App { .await; } - pub(super) fn begin_switch_to_worktree_target(&mut self, tui: &mut tui::Tui, target: String) { - self.show_worktree_switching_view(tui, target.clone()); - self.defer_switch_to_worktree_target(target); - } - - pub(super) fn current_worktree_selected(&mut self, target: String) { - self.chat_widget - .add_info_message(format!("Already in worktree {target}."), /*hint*/ None); - } - - pub(super) async fn switch_to_worktree_target_after_loading( + pub(super) async fn begin_switch_to_worktree_target( &mut self, tui: &mut tui::Tui, - app_server: &mut AppServerSession, + app_server: &AppServerSession, target: String, ) { let entries = match self @@ -234,6 +221,26 @@ impl App { return; } }; + self.begin_switch_to_worktree_info(tui, info); + } + + pub(super) fn begin_switch_to_worktree_info(&mut self, tui: &mut tui::Tui, info: WorktreeInfo) { + let target = info.branch.clone().unwrap_or_else(|| info.name.clone()); + self.show_worktree_switching_view(tui, target); + self.defer_switch_to_worktree_info(info); + } + + pub(super) fn current_worktree_selected(&mut self, target: String) { + self.chat_widget + .add_info_message(format!("Already in worktree {target}."), /*hint*/ None); + } + + pub(super) async fn switch_to_worktree_info_after_loading( + &mut self, + tui: &mut tui::Tui, + app_server: &mut AppServerSession, + info: WorktreeInfo, + ) { self.switch_to_worktree_info(tui, app_server, info, Vec::new()) .await; } @@ -249,10 +256,8 @@ impl App { { Ok(entries) => match crate::worktree::find_worktree(&entries, &target) { Ok(info) => { - self.chat_widget.add_info_message( - info.workspace_cwd.display().to_string(), - /*hint*/ None, - ); + self.chat_widget + .add_info_message(info.workspace_cwd.clone(), /*hint*/ None); } Err(err) => self.chat_widget.add_error_message(err), }, @@ -334,17 +339,13 @@ impl App { }, }) .await?; - Ok(response - .data - .into_iter() - .map(worktree_info_from_api) - .collect()) + Ok(response.data) } async fn source_worktree_dirty_state( &self, app_server: &AppServerSession, - ) -> anyhow::Result { + ) -> anyhow::Result { let response: WorktreeInspectSourceResponse = app_server .request_handle() .request_typed(ClientRequest::WorktreeInspectSource { @@ -354,7 +355,7 @@ impl App { }, }) .await?; - Ok(dirty_state_from_api(response.dirty)) + Ok(response.dirty) } fn session_workspace_cwd<'a>(&'a self, app_server: &'a AppServerSession) -> &'a Path { @@ -375,9 +376,12 @@ impl App { fn spawn_worktree_create_request( &self, app_server: &AppServerSession, - request: WorktreeRequest, + source_cwd: PathBuf, + branch: String, + base_ref: Option, + dirty_policy: WorktreeDirtyPolicy, ) { - let cwd = request.source_cwd.clone(); + let cwd = source_cwd.clone(); let app_event_tx = self.app_event_tx.clone(); let request_handle = app_server.request_handle(); tokio::spawn(async move { @@ -385,16 +389,14 @@ impl App { .request_typed(ClientRequest::WorktreeCreate { request_id: worktree_request_id("worktree-create"), params: WorktreeCreateParams { - cwd: Some(request.source_cwd.display().to_string()), - branch: request.branch, - base_ref: request.base_ref, - dirty_policy: dirty_policy_to_api(request.dirty_policy), + cwd: Some(source_cwd.display().to_string()), + branch, + base_ref, + dirty_policy, }, }) .await; - let result = result - .map(worktree_resolution_from_api) - .map_err(|err| err.to_string()); + let result = result.map_err(|err| err.to_string()); app_event_tx.send(AppEvent::WorktreeCreated { cwd, result }); }); } @@ -410,7 +412,7 @@ impl App { self.config.clone() } else { match self - .rebuild_config_for_cwd(info.workspace_cwd.clone()) + .rebuild_config_for_cwd(PathBuf::from(info.workspace_cwd.clone())) .await { Ok(config) => config, @@ -434,12 +436,15 @@ impl App { &mut self, tui: &mut tui::Tui, app_server: &mut AppServerSession, - info: WorktreeInfo, - config: Config, - forked: bool, - warnings: Vec, - result: Result, + args: WorktreeSessionReadyArgs, ) { + let WorktreeSessionReadyArgs { + info, + config, + forked, + warnings, + result, + } = args; match result { Ok(started) => { self.shutdown_current_thread(app_server).await; @@ -456,7 +461,9 @@ impl App { ); } else { if app_server.is_remote() { - app_server.set_remote_cwd_override(Some(info.workspace_cwd.clone())); + app_server.set_remote_cwd_override(Some(PathBuf::from( + info.workspace_cwd.clone(), + ))); } let transition = if forked { WorktreeSessionTransition::Forked @@ -491,7 +498,7 @@ impl App { ) { let request_handle = app_server.request_handle(); let remote_cwd_override = if app_server.is_remote() { - Some(info.workspace_cwd.clone()) + Some(PathBuf::from(info.workspace_cwd.clone())) } else { app_server.remote_cwd_override().map(Path::to_path_buf) }; @@ -600,11 +607,11 @@ impl App { tui.frame_requester().schedule_frame(); } - fn defer_switch_to_worktree_target(&self, target: String) { + fn defer_switch_to_worktree_info(&self, info: WorktreeInfo) { let app_event_tx = self.app_event_tx.clone(); tokio::spawn(async move { tokio::time::sleep(WORKTREE_SWITCH_RENDER_DELAY).await; - app_event_tx.send(AppEvent::SwitchToWorktreeAfterLoading { target }); + app_event_tx.send(AppEvent::SwitchToWorktreeAfterLoading { info }); }); } @@ -658,75 +665,8 @@ fn worktree_request_id(prefix: &str) -> RequestId { RequestId::String(format!("{prefix}-{}", Uuid::new_v4())) } -fn dirty_policy_to_api(value: DirtyPolicy) -> ApiWorktreeDirtyPolicy { - match value { - DirtyPolicy::Fail => ApiWorktreeDirtyPolicy::Fail, - DirtyPolicy::Ignore => ApiWorktreeDirtyPolicy::Ignore, - DirtyPolicy::CopyTracked => ApiWorktreeDirtyPolicy::CopyTracked, - DirtyPolicy::CopyAll => ApiWorktreeDirtyPolicy::CopyAll, - DirtyPolicy::MoveTracked => ApiWorktreeDirtyPolicy::MoveTracked, - DirtyPolicy::MoveAll => ApiWorktreeDirtyPolicy::MoveAll, - } -} - -fn dirty_state_from_api(value: ApiWorktreeDirtyState) -> codex_worktree::DirtyState { - codex_worktree::DirtyState { - has_staged_changes: value.has_staged_changes, - has_unstaged_changes: value.has_unstaged_changes, - has_untracked_files: value.has_untracked_files, - } -} - -fn worktree_info_from_api(value: ApiWorktreeInfo) -> WorktreeInfo { - WorktreeInfo { - id: value.id, - name: value.name, - slug: value.slug, - source: worktree_source_from_api(value.source), - location: worktree_location_from_api(value.location), - repo_name: value.repo_name, - repo_root: PathBuf::from(value.repo_root), - common_git_dir: PathBuf::from(value.common_git_dir), - worktree_git_root: PathBuf::from(value.worktree_git_root), - workspace_cwd: PathBuf::from(value.workspace_cwd), - original_relative_cwd: PathBuf::from(value.original_relative_cwd), - branch: value.branch, - head: value.head, - owner_thread_id: value.owner_thread_id, - metadata_path: PathBuf::from(value.metadata_path), - dirty: dirty_state_from_api(value.dirty), - } -} - -fn worktree_source_from_api(value: ApiWorktreeSource) -> WorktreeSource { - match value { - ApiWorktreeSource::Cli => WorktreeSource::Cli, - ApiWorktreeSource::App => WorktreeSource::App, - ApiWorktreeSource::Legacy => WorktreeSource::Legacy, - ApiWorktreeSource::Git => WorktreeSource::Git, - } -} - -fn worktree_location_from_api(value: ApiWorktreeLocation) -> WorktreeLocation { - match value { - ApiWorktreeLocation::Sibling => WorktreeLocation::Sibling, - ApiWorktreeLocation::CodexHome => WorktreeLocation::CodexHome, - ApiWorktreeLocation::External => WorktreeLocation::External, - } -} - -fn worktree_resolution_from_api(value: WorktreeCreateResponse) -> WorktreeResolution { - WorktreeResolution { - reused: value.reused, - info: worktree_info_from_api(value.info), - warnings: value - .warnings - .into_iter() - .map(|warning| WorktreeWarning { - message: warning.message, - }) - .collect(), - } +fn dirty_state_is_dirty(dirty: &WorktreeDirtyState) -> bool { + dirty.has_staged_changes || dirty.has_unstaged_changes || dirty.has_untracked_files } fn worktree_session_message( @@ -734,7 +674,7 @@ fn worktree_session_message( transition: WorktreeSessionTransition, ) -> (String, String) { let worktree_name = info.branch.as_deref().unwrap_or(info.name.as_str()); - let state = if info.dirty.is_dirty() { + let state = if dirty_state_is_dirty(&info.dirty) { "dirty" } else { "clean" @@ -746,7 +686,7 @@ fn worktree_session_message( transition.message_prefix(), info.repo_name ), - info.workspace_cwd.display().to_string(), + info.workspace_cwd.clone(), ) } @@ -754,11 +694,10 @@ fn worktree_session_message( mod tests { use super::*; use codex_app_server_protocol::AskForApproval; + use codex_app_server_protocol::WorktreeLocation; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::models::PermissionProfile; use codex_utils_absolute_path::AbsolutePathBuf; - use codex_worktree::DirtyState; - use codex_worktree::WorktreeLocation; use tempfile::TempDir; #[tokio::test] @@ -918,16 +857,16 @@ mod tests { source, location: WorktreeLocation::Sibling, repo_name: "codex".to_string(), - repo_root: path.clone(), - common_git_dir: PathBuf::from("/repo/codex/.git"), - worktree_git_root: path.clone(), - workspace_cwd: path, - original_relative_cwd: PathBuf::new(), + repo_root: path.to_string_lossy().to_string(), + common_git_dir: "/repo/codex/.git".to_string(), + worktree_git_root: path.to_string_lossy().to_string(), + workspace_cwd: path.to_string_lossy().to_string(), + original_relative_cwd: String::new(), branch, head: Some("abcdef".to_string()), owner_thread_id: None, - metadata_path: PathBuf::from("/repo/codex/.git/codex-worktree.json"), - dirty: DirtyState { + metadata_path: "/repo/codex/.git/codex-worktree.json".to_string(), + dirty: WorktreeDirtyState { has_staged_changes: false, has_unstaged_changes: dirty, has_untracked_files: false, diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 3e64344c6700..6740f32295b3 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -26,12 +26,14 @@ use codex_app_server_protocol::PluginUninstallResponse; use codex_app_server_protocol::RateLimitSnapshot; use codex_app_server_protocol::SkillsListResponse; use codex_app_server_protocol::ThreadGoalStatus; +use codex_app_server_protocol::WorktreeCreateResponse; +use codex_app_server_protocol::WorktreeDirtyPolicy; +use codex_app_server_protocol::WorktreeInfo; use codex_file_search::FileMatch; use codex_protocol::ThreadId; use codex_protocol::openai_models::ModelPreset; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_approval_presets::ApprovalPreset; -use codex_worktree::DirtyPolicy; use crate::app_command::AppCommand; use crate::app_server_session::AppServerStartedThread; @@ -209,13 +211,13 @@ pub(crate) enum AppEvent { CreateWorktreeAndSwitch { branch: String, base_ref: Option, - dirty_policy: Option, + dirty_policy: Option, }, /// Result of creating or reusing a managed worktree. WorktreeCreated { cwd: PathBuf, - result: Result, + result: Result, }, /// Switch the TUI into an existing worktree. @@ -223,6 +225,11 @@ pub(crate) enum AppEvent { target: String, }, + /// Switch the TUI into the selected existing worktree. + SwitchToWorktreeInfo { + info: WorktreeInfo, + }, + /// A picker row for the current worktree was selected. CurrentWorktreeSelected { target: String, @@ -230,12 +237,12 @@ pub(crate) enum AppEvent { /// Continue switching into an existing worktree after the loading view has rendered. SwitchToWorktreeAfterLoading { - target: String, + info: WorktreeInfo, }, /// Result of starting or forking a session in a worktree. WorktreeSessionReady { - info: codex_worktree::WorktreeInfo, + info: WorktreeInfo, config: Config, forked: bool, warnings: Vec, diff --git a/codex-rs/tui/src/worktree.rs b/codex-rs/tui/src/worktree.rs index 41a07b21a1b5..758c71f92aed 100644 --- a/codex-rs/tui/src/worktree.rs +++ b/codex-rs/tui/src/worktree.rs @@ -1,9 +1,10 @@ use std::path::Path; use std::time::Instant; -use codex_worktree::DirtyPolicy; -use codex_worktree::WorktreeInfo; -use codex_worktree::WorktreeSource; +use codex_app_server_protocol::WorktreeDirtyPolicy as DirtyPolicy; +use codex_app_server_protocol::WorktreeDirtyState; +use codex_app_server_protocol::WorktreeInfo; +use codex_app_server_protocol::WorktreeSource; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::style::Stylize; @@ -371,7 +372,7 @@ pub(crate) fn picker_params(entries: Vec, current_cwd: &Path) -> S items.extend(entries.into_iter().enumerate().map(|(idx, entry)| { let target = entry.branch.clone().unwrap_or_else(|| entry.name.clone()); let source = source_label(entry.source); - let status = if entry.dirty.is_dirty() { + let status = if dirty_state_is_dirty(&entry.dirty) { "dirty" } else { "clean" @@ -380,18 +381,15 @@ pub(crate) fn picker_params(entries: Vec, current_cwd: &Path) -> S if is_current { initial_selected_idx = Some(idx + 1); } - let description = format!("{status} · {source} · {}", entry.workspace_cwd.display()); + let description = format!("{status} · {source} · {}", entry.workspace_cwd); let selected_description = if is_current { "Already in this worktree".to_string() } else { - format!("Fork this chat into {}", entry.workspace_cwd.display()) + format!("Fork this chat into {}", entry.workspace_cwd) }; let search_value = Some(format!( "{} {} {} {}", - target, - entry.name, - source, - entry.workspace_cwd.display() + target, entry.name, source, entry.workspace_cwd )); let target_for_action = target.clone(); let actions: Vec = if is_current { @@ -401,9 +399,10 @@ pub(crate) fn picker_params(entries: Vec, current_cwd: &Path) -> S }); })] } else { + let info_for_action = entry; vec![Box::new(move |tx| { - tx.send(AppEvent::SwitchToWorktree { - target: target_for_action.clone(), + tx.send(AppEvent::SwitchToWorktreeInfo { + info: info_for_action.clone(), }); })] }; @@ -595,31 +594,34 @@ fn paths_match(a: &Path, b: &Path) -> bool { } fn is_current_worktree(current_cwd: &Path, entry: &WorktreeInfo) -> bool { - if paths_match(current_cwd, &entry.workspace_cwd) { + let workspace_cwd = Path::new(&entry.workspace_cwd); + if paths_match(current_cwd, workspace_cwd) { return true; } let current_cwd = current_cwd .canonicalize() .unwrap_or_else(|_| current_cwd.to_path_buf()); - let worktree_root = entry - .worktree_git_root + let worktree_git_root = Path::new(&entry.worktree_git_root); + let worktree_root = worktree_git_root .canonicalize() - .unwrap_or_else(|_| entry.worktree_git_root.clone()); + .unwrap_or_else(|_| worktree_git_root.to_path_buf()); current_cwd.starts_with(worktree_root) } +fn dirty_state_is_dirty(dirty: &WorktreeDirtyState) -> bool { + dirty.has_staged_changes || dirty.has_unstaged_changes || dirty.has_untracked_files +} + #[cfg(test)] mod tests { use super::*; - use std::path::PathBuf; use crate::app_event_sender::AppEventSender; use crate::bottom_pane::ListSelectionView; use crate::keymap::RuntimeKeymap; use crate::render::renderable::Renderable; use crate::tui::FrameRequester; - use codex_worktree::DirtyState; - use codex_worktree::WorktreeLocation; + use codex_app_server_protocol::WorktreeLocation; use pretty_assertions::assert_eq; use ratatui::buffer::Buffer; use ratatui::layout::Rect; @@ -734,6 +736,27 @@ mod tests { )); } + #[test] + fn existing_worktree_item_dispatches_selected_worktree_info() { + let (tx_raw, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let params = picker_params( + vec![sample_info( + "fcoury/worktrees", + WorktreeSource::Git, + /*dirty*/ false, + )], + Path::new("/repo/codex"), + ); + + (params.items[1].actions[0])(&tx); + + assert!(matches!( + rx.try_recv(), + Ok(AppEvent::SwitchToWorktreeInfo { info }) if info.branch.as_deref() == Some("fcoury/worktrees") + )); + } + #[test] fn worktree_loading_snapshot() { insta::assert_snapshot!( @@ -831,7 +854,7 @@ mod tests { } fn sample_info(branch: &str, source: WorktreeSource, dirty: bool) -> WorktreeInfo { - let path = PathBuf::from(format!("/repo/codex.{}", branch.replace('/', "-"))); + let path = format!("/repo/codex.{}", branch.replace('/', "-")); WorktreeInfo { id: "repo-id".to_string(), name: branch.to_string(), @@ -844,15 +867,15 @@ mod tests { }, repo_name: "codex".to_string(), repo_root: path.clone(), - common_git_dir: PathBuf::from("/repo/codex/.git"), + common_git_dir: "/repo/codex/.git".to_string(), worktree_git_root: path.clone(), workspace_cwd: path, - original_relative_cwd: PathBuf::new(), + original_relative_cwd: String::new(), branch: Some(branch.to_string()), head: Some("abcdef".to_string()), owner_thread_id: None, - metadata_path: PathBuf::from("/repo/codex/.git/codex-worktree.json"), - dirty: DirtyState { + metadata_path: "/repo/codex/.git/codex-worktree.json".to_string(), + dirty: WorktreeDirtyState { has_staged_changes: false, has_unstaged_changes: dirty, has_untracked_files: false, diff --git a/codex-rs/worktree/src/lib.rs b/codex-rs/worktree/src/lib.rs index 6accd4193075..8a9d68ab6230 100644 --- a/codex-rs/worktree/src/lib.rs +++ b/codex-rs/worktree/src/lib.rs @@ -14,6 +14,7 @@ pub use dirty::DirtyState; pub use dirty::dirty_state; pub use manager::ensure_worktree; pub use manager::list_worktrees; +pub use manager::prune_stale_managed_worktree_dirs; pub use manager::remove_worktree; pub use manager::resolve_worktree; pub use manager::stale_managed_worktree_dirs; diff --git a/codex-rs/worktree/src/manager.rs b/codex-rs/worktree/src/manager.rs index 9fbdc2e9b046..d22c3cad6152 100644 --- a/codex-rs/worktree/src/manager.rs +++ b/codex-rs/worktree/src/manager.rs @@ -346,6 +346,23 @@ pub fn stale_managed_worktree_dirs(codex_home: &Path) -> Result> { Ok(stale) } +pub fn prune_stale_managed_worktree_dirs(codex_home: &Path, dry_run: bool) -> Result> { + let stale = stale_managed_worktree_dirs(codex_home)?; + if dry_run { + return Ok(stale); + } + + for path in &stale { + fs::remove_dir_all(path).with_context(|| { + format!( + "failed to remove stale worktree directory {}", + path.display() + ) + })?; + } + Ok(stale) +} + fn git_root_is_valid(path: &Path) -> bool { std::process::Command::new("git") .args(["rev-parse", "--show-toplevel"]) @@ -689,6 +706,7 @@ fn parse_worktree_list(output: &str) -> Vec { mod tests { use super::*; use pretty_assertions::assert_eq; + use tempfile::TempDir; #[test] fn parse_worktree_list_preserves_branches() { @@ -709,4 +727,27 @@ mod tests { ] ); } + + #[test] + fn prune_stale_managed_worktree_dirs_respects_dry_run() -> anyhow::Result<()> { + let codex_home = TempDir::new()?; + let stale_path = paths::codex_worktrees_root(codex_home.path()) + .join("repo-id") + .join("demo") + .join("codex.demo"); + fs::create_dir_all(&stale_path)?; + + assert_eq!( + prune_stale_managed_worktree_dirs(codex_home.path(), /*dry_run*/ true)?, + vec![stale_path.clone()] + ); + assert!(stale_path.exists()); + + assert_eq!( + prune_stale_managed_worktree_dirs(codex_home.path(), /*dry_run*/ false)?, + vec![stale_path.clone()] + ); + assert!(!stale_path.exists()); + Ok(()) + } }