From 5a6efcf183cce7309a35cddcbdb5349f78de45a8 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Wed, 6 May 2026 19:44:04 -0300 Subject: [PATCH] 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 f78f27ffa3..d914890f55 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 f1673d3bab..c95dbb415d 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 b644734f4c..89249654ea 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 f3545629c1..fa2cc62a52 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 632e479404..94d77a2fc2 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 a26dc525e6..db54d1be88 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 5ef87bd9b9..6aa03c243e 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 d6d65b04a4..176663058a 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 482c75b3fa..3dde7d6cf8 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 1a7986ad31..4ec0522204 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 89fd2600f8..c9e114e0a0 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 6a170beec7..92d59d3027 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 b2e92a19b4..26ec4ec6e2 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 08bd6657bf..d2357e6e7d 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 0000000000..2976869e6b --- /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 35e4fd1daf..f1294f3eb0 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 c174a7b5a6..460ece0e37 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 0000000000..b7843957dd --- /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 0000000000..8233781f0f --- /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 0000000000..b137a87a20 --- /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 0000000000..5d2a13bda4 --- /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 0000000000..43acfca47c --- /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 0000000000..a5adb60bc7 --- /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 0000000000..e0b20248e9 --- /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 0000000000..f2b09308cd --- /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 0000000000..c99b7565fc --- /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 0000000000..6f9bbc4cdc --- /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()) +}