From 7a113fc7e99bb2d61fac4bd6f2275ed6579a974d Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Tue, 12 May 2026 12:34:45 -0300 Subject: [PATCH] feat(tui): add managed worktree flow --- codex-rs/Cargo.lock | 17 + codex-rs/Cargo.toml | 2 + .../schema/json/ClientRequest.json | 4 + .../codex_app_server_protocol.schemas.json | 4 + .../codex_app_server_protocol.v2.schemas.json | 4 + .../schema/json/v2/CommandExecParams.json | 4 + .../schema/typescript/v2/CommandExecParams.ts | 6 + .../src/protocol/common.rs | 3 + .../src/protocol/mappers.rs | 1 + .../src/protocol/v2/command_exec.rs | 6 + .../src/protocol/v2/tests.rs | 5 + .../command_exec_processor.rs | 10 +- .../app-server/tests/suite/v2/command_exec.rs | 15 + codex-rs/arg0/src/lib.rs | 5 + codex-rs/cli/Cargo.toml | 3 + codex-rs/cli/src/main.rs | 801 ++++++++++++ codex-rs/exec/src/lib.rs | 3 + codex-rs/tui/Cargo.toml | 1 + codex-rs/tui/src/app.rs | 4 + codex-rs/tui/src/app/event_dispatch.rs | 61 + codex-rs/tui/src/app/startup_prompts.rs | 6 + codex-rs/tui/src/app/thread_routing.rs | 3 + codex-rs/tui/src/app/worktree.rs | 1141 +++++++++++++++++ codex-rs/tui/src/app_event.rs | 74 ++ codex-rs/tui/src/app_server_session.rs | 101 ++ codex-rs/tui/src/chatwidget.rs | 24 + codex-rs/tui/src/chatwidget/slash_dispatch.rs | 11 + .../tui/src/chatwidget/status_surfaces.rs | 20 +- codex-rs/tui/src/lib.rs | 91 +- codex-rs/tui/src/motion.rs | 29 + codex-rs/tui/src/resume_picker.rs | 126 +- codex-rs/tui/src/slash_command.rs | 4 + ...i__worktree__tests__worktree_creating.snap | 13 + ...__tests__worktree_dirty_policy_prompt.snap | 14 + ..._tui__worktree__tests__worktree_empty.snap | 10 + ...ui__worktree__tests__worktree_loading.snap | 13 + ...tui__worktree__tests__worktree_picker.snap | 15 + ...__tests__worktree_remove_confirmation.snap | 12 + ...__worktree__tests__worktree_switching.snap | 13 + codex-rs/tui/src/workspace_command.rs | 26 +- codex-rs/tui/src/worktree.rs | 899 +++++++++++++ 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 | 13 + codex-rs/worktree/BUILD.bazel | 7 + codex-rs/worktree/Cargo.toml | 19 + codex-rs/worktree/src/dirty.rs | 303 +++++ codex-rs/worktree/src/git.rs | 57 + codex-rs/worktree/src/lib.rs | 111 ++ codex-rs/worktree/src/manager.rs | 636 +++++++++ codex-rs/worktree/src/metadata.rs | 157 +++ codex-rs/worktree/src/paths.rs | 105 ++ codex-rs/worktree/tests/git_backend.rs | 516 ++++++++ 54 files changed, 5585 insertions(+), 34 deletions(-) create mode 100644 codex-rs/tui/src/app/worktree.rs 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_dirty_policy_prompt.snap create mode 100644 codex-rs/tui/src/snapshots/codex_tui__worktree__tests__worktree_empty.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 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 ec654051e4..f36c0320ef 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2227,6 +2227,7 @@ dependencies = [ "clap", "clap_complete", "codex-app-server", + "codex-app-server-client", "codex-app-server-daemon", "codex-app-server-protocol", "codex-app-server-test-client", @@ -2259,11 +2260,13 @@ dependencies = [ "codex-utils-cli", "codex-utils-path", "codex-windows-sandbox", + "codex-worktree", "libc", "owo-colors", "predicates", "pretty_assertions", "regex-lite", + "serde", "serde_json", "sqlx", "supports-color 3.0.2", @@ -3753,6 +3756,7 @@ dependencies = [ "codex-utils-sleep-inhibitor", "codex-utils-string", "codex-windows-sandbox", + "codex-worktree", "color-eyre", "cpal", "crossterm", @@ -4061,6 +4065,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 dd0cd7e491..38a5bbf849 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -75,6 +75,7 @@ members = [ "otel", "tui", "tools", + "worktree", "v8-poc", "utils/absolute-path", "utils/cargo-bin", @@ -208,6 +209,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/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 6351993046..bd05f51f17 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -266,6 +266,10 @@ "tty": { "description": "Enable PTY mode.\n\nThis implies `streamStdin` and `streamStdoutStderr`.", "type": "boolean" + }, + "useCodexSelfExe": { + "description": "Replace argv[0] with the app-server's own Codex executable.\n\nThis is for internal helper commands that must run on the same machine as the app-server while still using the generic command execution path.", + "type": "boolean" } }, "required": [ 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 215842d929..a25dac39ea 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 @@ -6862,6 +6862,10 @@ "tty": { "description": "Enable PTY mode.\n\nThis implies `streamStdin` and `streamStdoutStderr`.", "type": "boolean" + }, + "useCodexSelfExe": { + "description": "Replace argv[0] with the app-server's own Codex executable.\n\nThis is for internal helper commands that must run on the same machine as the app-server while still using the generic command execution path.", + "type": "boolean" } }, "required": [ 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 085744e819..f3e42488b4 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 @@ -3251,6 +3251,10 @@ "tty": { "description": "Enable PTY mode.\n\nThis implies `streamStdin` and `streamStdoutStderr`.", "type": "boolean" + }, + "useCodexSelfExe": { + "description": "Replace argv[0] with the app-server's own Codex executable.\n\nThis is for internal helper commands that must run on the same machine as the app-server while still using the generic command execution path.", + "type": "boolean" } }, "required": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/CommandExecParams.json b/codex-rs/app-server-protocol/schema/json/v2/CommandExecParams.json index f29483862c..186ebfc5ea 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/CommandExecParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/CommandExecParams.json @@ -553,6 +553,10 @@ "tty": { "description": "Enable PTY mode.\n\nThis implies `streamStdin` and `streamStdoutStderr`.", "type": "boolean" + }, + "useCodexSelfExe": { + "description": "Replace argv[0] with the app-server's own Codex executable.\n\nThis is for internal helper commands that must run on the same machine as the app-server while still using the generic command execution path.", + "type": "boolean" } }, "required": [ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecParams.ts index 221a2399c1..1b354ffb25 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecParams.ts @@ -16,6 +16,12 @@ export type CommandExecParams = {/** * Command argv vector. Empty arrays are rejected. */ command: Array, /** + * Replace argv[0] with the app-server's own Codex executable. + * + * This is for internal helper commands that must run on the same machine + * as the app-server while still using the generic command execution path. + */ +useCodexSelfExe?: boolean, /** * Optional client-supplied, connection-scoped process id. * * Required for `tty`, `streamStdin`, `streamStdoutStderr`, and follow-up diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index ae00b08b73..2ab124179e 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -1601,6 +1601,7 @@ mod tests { request_id: request_id(), params: v2::CommandExecParams { command: vec!["sleep".to_string(), "10".to_string()], + use_codex_self_exe: false, process_id: Some("proc-1".to_string()), tty: false, stream_stdin: false, @@ -1842,6 +1843,7 @@ mod tests { request_id: request_id(), params: v2::CommandExecParams { command: vec!["true".to_string()], + use_codex_self_exe: false, process_id: None, tty: false, stream_stdin: false, @@ -2911,6 +2913,7 @@ mod tests { request_id: RequestId::Integer(1), params: v2::CommandExecParams { command: vec!["pwd".to_string()], + use_codex_self_exe: false, process_id: None, tty: false, stream_stdin: false, diff --git a/codex-rs/app-server-protocol/src/protocol/mappers.rs b/codex-rs/app-server-protocol/src/protocol/mappers.rs index dae91e650f..6fac7ed079 100644 --- a/codex-rs/app-server-protocol/src/protocol/mappers.rs +++ b/codex-rs/app-server-protocol/src/protocol/mappers.rs @@ -4,6 +4,7 @@ impl From for v2::CommandExecParams { fn from(value: v1::ExecOneOffCommandParams) -> Self { Self { command: value.command, + use_codex_self_exe: false, process_id: None, tty: false, stream_stdin: false, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/command_exec.rs b/codex-rs/app-server-protocol/src/protocol/v2/command_exec.rs index ff0cecf4f9..a240b305c9 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/command_exec.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/command_exec.rs @@ -31,6 +31,12 @@ pub struct CommandExecTerminalSize { pub struct CommandExecParams { /// Command argv vector. Empty arrays are rejected. pub command: Vec, + /// Replace argv[0] with the app-server's own Codex executable. + /// + /// This is for internal helper commands that must run on the same machine + /// as the app-server while still using the generic command execution path. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub use_codex_self_exe: bool, /// Optional client-supplied, connection-scoped process id. /// /// Required for `tty`, `streamStdin`, `streamStdoutStderr`, and follow-up diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index 30599776ae..aacda8b14d 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -858,6 +858,7 @@ fn command_exec_params_default_optional_streaming_flags() { params, CommandExecParams { command: vec!["ls".to_string(), "-la".to_string()], + use_codex_self_exe: false, process_id: None, tty: false, stream_stdin: false, @@ -879,6 +880,7 @@ fn command_exec_params_default_optional_streaming_flags() { fn command_exec_params_round_trips_disable_timeout() { let params = CommandExecParams { command: vec!["sleep".to_string(), "30".to_string()], + use_codex_self_exe: false, process_id: Some("sleep-1".to_string()), tty: false, stream_stdin: false, @@ -1011,6 +1013,7 @@ fn process_spawn_params_distinguish_omitted_null_and_value_limits() { fn command_exec_params_round_trips_disable_output_cap() { let params = CommandExecParams { command: vec!["yes".to_string()], + use_codex_self_exe: false, process_id: Some("yes-1".to_string()), tty: false, stream_stdin: false, @@ -1053,6 +1056,7 @@ fn command_exec_params_round_trips_disable_output_cap() { fn command_exec_params_round_trips_env_overrides_and_unsets() { let params = CommandExecParams { command: vec!["printenv".to_string(), "FOO".to_string()], + use_codex_self_exe: false, process_id: Some("env-1".to_string()), tty: false, stream_stdin: false, @@ -1143,6 +1147,7 @@ fn command_exec_terminate_round_trips() { fn command_exec_params_round_trip_with_size() { let params = CommandExecParams { command: vec!["top".to_string()], + use_codex_self_exe: false, process_id: Some("pty-1".to_string()), tty: true, stream_stdin: false, diff --git a/codex-rs/app-server/src/request_processors/command_exec_processor.rs b/codex-rs/app-server/src/request_processors/command_exec_processor.rs index 3236a67627..3ad2315df4 100644 --- a/codex-rs/app-server/src/request_processors/command_exec_processor.rs +++ b/codex-rs/app-server/src/request_processors/command_exec_processor.rs @@ -94,7 +94,8 @@ impl CommandExecRequestProcessor { } let CommandExecParams { - command, + mut command, + use_codex_self_exe, process_id, tty, stream_stdin, @@ -115,6 +116,13 @@ impl CommandExecRequestProcessor { )); } + if use_codex_self_exe { + let codex_self_exe = self.arg0_paths.codex_self_exe.as_ref().ok_or_else(|| { + internal_error("app-server does not know its Codex executable path") + })?; + command[0] = codex_self_exe.to_string_lossy().into_owned(); + } + if size.is_some() && !tty { return Err(invalid_params("command/exec size requires tty: true")); } diff --git a/codex-rs/app-server/tests/suite/v2/command_exec.rs b/codex-rs/app-server/tests/suite/v2/command_exec.rs index 211cec9355..2417b51581 100644 --- a/codex-rs/app-server/tests/suite/v2/command_exec.rs +++ b/codex-rs/app-server/tests/suite/v2/command_exec.rs @@ -53,6 +53,7 @@ async fn command_exec_without_streams_can_be_terminated() -> Result<()> { let command_request_id = mcp .send_command_exec_request(CommandExecParams { command: vec!["sh".to_string(), "-lc".to_string(), "sleep 30".to_string()], + use_codex_self_exe: false, process_id: Some(process_id.clone()), tty: false, stream_stdin: false, @@ -106,6 +107,7 @@ async fn command_exec_without_process_id_keeps_buffered_compatibility() -> Resul "-lc".to_string(), "printf 'legacy-out'; printf 'legacy-err' >&2".to_string(), ], + use_codex_self_exe: false, process_id: None, tty: false, stream_stdin: false, @@ -158,6 +160,7 @@ async fn command_exec_env_overrides_merge_with_server_environment_and_support_un "-lc".to_string(), "printf '%s|%s|%s|%s' \"$COMMAND_EXEC_BASELINE\" \"$COMMAND_EXEC_EXTRA\" \"${RUST_LOG-unset}\" \"$CODEX_HOME\"".to_string(), ], + use_codex_self_exe: false, process_id: None, tty: false, stream_stdin: false, @@ -212,6 +215,7 @@ async fn command_exec_accepts_permission_profile() -> Result<()> { "-lc".to_string(), "printf 'profile'".to_string(), ], + use_codex_self_exe: false, process_id: None, tty: false, stream_stdin: false, @@ -276,6 +280,7 @@ async fn command_exec_permission_profile_project_roots_use_command_cwd() -> Resu "-lc".to_string(), "printf child > child.txt && ! printf parent > ../parent.txt".to_string(), ], + use_codex_self_exe: false, process_id: None, tty: false, stream_stdin: false, @@ -323,6 +328,7 @@ async fn command_exec_rejects_sandbox_policy_with_permission_profile() -> Result let command_request_id = mcp .send_command_exec_request(CommandExecParams { command: vec!["sh".to_string(), "-lc".to_string(), "true".to_string()], + use_codex_self_exe: false, process_id: None, tty: false, stream_stdin: false, @@ -361,6 +367,7 @@ async fn command_exec_rejects_disable_timeout_with_timeout_ms() -> Result<()> { let command_request_id = mcp .send_command_exec_request(CommandExecParams { command: vec!["sh".to_string(), "-lc".to_string(), "sleep 1".to_string()], + use_codex_self_exe: false, process_id: Some("invalid-timeout-1".to_string()), tty: false, stream_stdin: false, @@ -399,6 +406,7 @@ async fn command_exec_rejects_disable_output_cap_with_output_bytes_cap() -> Resu let command_request_id = mcp .send_command_exec_request(CommandExecParams { command: vec!["sh".to_string(), "-lc".to_string(), "sleep 1".to_string()], + use_codex_self_exe: false, process_id: Some("invalid-cap-1".to_string()), tty: false, stream_stdin: false, @@ -437,6 +445,7 @@ async fn command_exec_rejects_negative_timeout_ms() -> Result<()> { let command_request_id = mcp .send_command_exec_request(CommandExecParams { command: vec!["sh".to_string(), "-lc".to_string(), "sleep 1".to_string()], + use_codex_self_exe: false, process_id: Some("negative-timeout-1".to_string()), tty: false, stream_stdin: false, @@ -475,6 +484,7 @@ async fn command_exec_without_process_id_rejects_streaming() -> Result<()> { let command_request_id = mcp .send_command_exec_request(CommandExecParams { command: vec!["sh".to_string(), "-lc".to_string(), "cat".to_string()], + use_codex_self_exe: false, process_id: None, tty: false, stream_stdin: false, @@ -517,6 +527,7 @@ async fn command_exec_non_streaming_respects_output_cap() -> Result<()> { "-lc".to_string(), "printf 'abcdef'; printf 'uvwxyz' >&2".to_string(), ], + use_codex_self_exe: false, process_id: Some("cap-1".to_string()), tty: false, stream_stdin: false, @@ -565,6 +576,7 @@ async fn command_exec_streaming_does_not_buffer_output() -> Result<()> { "-lc".to_string(), "printf 'abcdefghij'; sleep 30".to_string(), ], + use_codex_self_exe: false, process_id: Some(process_id.clone()), tty: false, stream_stdin: false, @@ -629,6 +641,7 @@ async fn command_exec_pipe_streams_output_and_accepts_write() -> Result<()> { "-lc".to_string(), "printf 'out-start\\n'; printf 'err-start\\n' >&2; IFS= read line; printf 'out:%s\\n' \"$line\"; printf 'err:%s\\n' \"$line\" >&2".to_string(), ], + use_codex_self_exe: false, process_id: Some(process_id.clone()), tty: false, stream_stdin: true, @@ -705,6 +718,7 @@ async fn command_exec_tty_implies_streaming_and_reports_pty_output() -> Result<( "-lc".to_string(), "stty -echo; if [ -t 0 ]; then printf 'tty\\n'; else printf 'notty\\n'; fi; IFS= read line; printf 'echo:%s\\n' \"$line\"".to_string(), ], + use_codex_self_exe: false, process_id: Some(process_id.clone()), tty: true, stream_stdin: false, @@ -776,6 +790,7 @@ async fn command_exec_tty_supports_initial_size_and_resize() -> Result<()> { "-lc".to_string(), "stty -echo; printf 'start:%s\\n' \"$(stty size)\"; IFS= read _line; printf 'after:%s\\n' \"$(stty size)\"".to_string(), ], + use_codex_self_exe: false, process_id: Some(process_id.clone()), tty: true, stream_stdin: false, diff --git a/codex-rs/arg0/src/lib.rs b/codex-rs/arg0/src/lib.rs index 2f6ae4653c..cf4aa86b29 100644 --- a/codex-rs/arg0/src/lib.rs +++ b/codex-rs/arg0/src/lib.rs @@ -15,6 +15,7 @@ const APPLY_PATCH_ARG0: &str = "apply_patch"; const MISSPELLED_APPLY_PATCH_ARG0: &str = "applypatch"; #[cfg(unix)] const EXECVE_WRAPPER_ARG0: &str = "codex-execve-wrapper"; +pub const CODEX_ARG0_SKIP_PATH_UPDATE_ENV_VAR: &str = "CODEX_ARG0_SKIP_PATH_UPDATE"; const LOCK_FILENAME: &str = ".lock"; const TOKIO_WORKER_STACK_SIZE_BYTES: usize = 16 * 1024 * 1024; @@ -138,6 +139,10 @@ pub fn arg0_dispatch() -> Option { // before creating any threads/the Tokio runtime. load_dotenv(); + if std::env::var_os(CODEX_ARG0_SKIP_PATH_UPDATE_ENV_VAR).is_some() { + return None; + } + match prepend_path_entry_for_codex_aliases() { Ok(path_entry) => Some(path_entry), Err(err) => { diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index 120d2e497a..cadfc7a6da 100644 --- a/codex-rs/cli/Cargo.toml +++ b/codex-rs/cli/Cargo.toml @@ -22,6 +22,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-daemon = { workspace = true } codex-app-server-protocol = { workspace = true } codex-app-server-test-client = { workspace = true } @@ -50,11 +51,13 @@ 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 } owo-colors = { workspace = true } regex-lite = { workspace = true } +serde = { workspace = true } serde_json = { workspace = true } supports-color = { workspace = true } tempfile = { workspace = true } diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index d70e401664..d90d82e9b3 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -1,11 +1,19 @@ +use anyhow::Context as _; use clap::Args; use clap::CommandFactory; use clap::Parser; use clap_complete::Shell; use clap_complete::generate; +use codex_app_server_client::RemoteAppServerClient; +use codex_app_server_client::RemoteAppServerConnectArgs; use codex_app_server_daemon::BootstrapOptions as AppServerBootstrapOptions; use codex_app_server_daemon::LifecycleCommand as AppServerLifecycleCommand; use codex_app_server_daemon::RemoteControlMode as AppServerRemoteControlMode; +use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::CommandExecParams; +use codex_app_server_protocol::CommandExecResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::SandboxPolicy; use codex_arg0::Arg0DispatchPaths; use codex_arg0::arg0_dispatch_or_else; use codex_chatgpt::apply_command::ApplyCommand; @@ -37,8 +45,21 @@ 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 serde::Serialize; +use serde::de::DeserializeOwned; +use std::collections::HashMap; +use std::fs; use std::io::IsTerminal; +use std::path::Path; use std::path::PathBuf; use supports_color::Stream; @@ -162,6 +183,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), @@ -325,6 +349,134 @@ 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 { + /// Create or reuse a Codex-managed worktree for the current repository. + Create(WorktreeCreateCommand), + + /// 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), + + /// Internal JSON worktree operations for TUI workspace commands. + #[clap(hide = true, name = "__internal")] + Internal(WorktreeInternalCommand), +} + +#[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 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. + 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, Args)] +struct WorktreeInternalCommand { + #[command(subcommand)] + subcommand: WorktreeInternalSubcommand, +} + +#[derive(Debug, clap::Subcommand)] +enum WorktreeInternalSubcommand { + /// List managed worktrees for the current repository. + List(WorktreeInternalListCommand), + + /// Inspect dirty state for the current repository. + InspectSource, + + /// Create or reuse a managed worktree for the current repository. + Create(WorktreeCreateCommand), + + /// Remove a managed worktree for the current repository. + Remove(WorktreeRemoveCommand), + + /// Bind the current managed worktree to a thread id. + BindThread(WorktreeBindThreadCommand), + + /// Remove stale Codex-managed worktree metadata. + Prune(WorktreePruneCommand), + + /// Resolve the current managed worktree, if any. + ResolveCurrent, +} + +#[derive(Debug, Args)] +struct WorktreeInternalListCommand { + /// Include managed worktrees from all repositories. + #[arg(long = "all", default_value_t = false)] + all: bool, +} + +#[derive(Debug, Args)] +struct WorktreeBindThreadCommand { + /// Thread id that now owns the current managed worktree. + thread_id: String, +} + #[derive(Debug, Parser)] struct SandboxArgs { #[command(subcommand)] @@ -705,6 +857,542 @@ 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() { + shared.worktree = Some(branch); + return Ok(None); + } + + 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, + WorktreeDirtyCliArg::MoveTracked => DirtyPolicy::MoveTracked, + WorktreeDirtyCliArg::MoveAll => DirtyPolicy::MoveAll, + } +} + +async fn run_worktree_command( + cli: WorktreeCli, + remote: Option, + remote_auth_token_env: Option, +) -> anyhow::Result<()> { + if remote.is_some() || remote_auth_token_env.is_some() { + return run_remote_worktree_command(cli, remote, remote_auth_token_env).await; + } + + 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 + } 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()); + } + } + } + } + WorktreeSubcommand::Internal(command) => { + run_worktree_internal_command(command, &codex_home)?; + } + } + Ok(()) +} + +fn run_worktree_internal_command( + command: WorktreeInternalCommand, + codex_home: &Path, +) -> anyhow::Result<()> { + let cwd = std::env::current_dir()?; + match command.subcommand { + WorktreeInternalSubcommand::List(command) => { + let entries = codex_worktree::list_worktrees(WorktreeListQuery { + codex_home: codex_home.to_path_buf(), + source_cwd: (!command.all).then_some(cwd), + include_all_repos: command.all, + })?; + print_json(&entries)?; + } + WorktreeInternalSubcommand::InspectSource => { + let dirty = codex_worktree::dirty_state(&cwd)?; + print_json(&dirty)?; + } + WorktreeInternalSubcommand::Create(command) => { + let resolution = codex_worktree::ensure_worktree(WorktreeRequest { + codex_home: codex_home.to_path_buf(), + source_cwd: cwd, + branch: command.branch, + base_ref: command.base_ref, + dirty_policy: dirty_policy_from_cli(command.dirty), + })?; + print_json(&resolution)?; + } + WorktreeInternalSubcommand::Remove(command) => { + let result = codex_worktree::remove_worktree(WorktreeRemoveRequest { + codex_home: codex_home.to_path_buf(), + source_cwd: Some(cwd), + name_or_path: command.name_or_path, + force: command.force, + delete_branch: command.delete_branch, + })?; + print_json(&result)?; + } + WorktreeInternalSubcommand::BindThread(command) => { + codex_worktree::bind_thread(&cwd, &command.thread_id)?; + print_json(&serde_json::json!({ "ok": true }))?; + } + WorktreeInternalSubcommand::Prune(command) => { + let stale_paths = stale_managed_worktree_dirs(codex_home)?; + if !(command.dry_run || command.json) { + for path in &stale_paths { + fs::remove_dir_all(path)?; + } + } + print_json(&stale_paths)?; + } + WorktreeInternalSubcommand::ResolveCurrent => { + let info = codex_worktree::resolve_worktree(codex_home, &cwd)?; + print_json(&info)?; + } + } + Ok(()) +} + +async fn run_remote_worktree_command( + cli: WorktreeCli, + remote: Option, + remote_auth_token_env: Option, +) -> anyhow::Result<()> { + let client = connect_remote_app_server_for_worktree(remote, remote_auth_token_env).await?; + match cli.subcommand { + WorktreeSubcommand::Create(command) => { + let resolution: WorktreeResolution = remote_worktree_helper( + &client, + worktree_create_helper_args(command.branch, command.base_ref, command.dirty), + /*allow_mutation*/ true, + ) + .await?; + for warning in &resolution.warnings { + eprintln!("warning: {}", warning.message); + } + println!("{}", resolution.info.workspace_cwd.display()); + } + WorktreeSubcommand::List(command) => { + let mut args = vec!["list".to_string()]; + if command.all { + args.push("--all".to_string()); + } + let entries: Vec = + remote_worktree_helper(&client, args, /*allow_mutation*/ false).await?; + print_worktree_list(entries, command.json)?; + } + WorktreeSubcommand::Path(command) => { + let entries: Vec = remote_worktree_helper( + &client, + vec!["list".to_string()], + /*allow_mutation*/ false, + ) + .await?; + let entry = find_named_worktree(entries, &command.name)?; + println!("{}", entry.workspace_cwd.display()); + } + WorktreeSubcommand::Remove(command) => { + let result: codex_worktree::WorktreeRemoveResult = remote_worktree_helper( + &client, + worktree_remove_helper_args( + command.name_or_path, + command.force, + command.delete_branch, + ), + /*allow_mutation*/ true, + ) + .await?; + println!("removed {}", result.removed_path.display()); + if let Some(branch) = result.deleted_branch { + println!("deleted branch {branch}"); + } + } + WorktreeSubcommand::Prune(command) => { + let mut args = vec!["prune".to_string()]; + if command.dry_run || command.json { + args.push("--dry-run".to_string()); + } + let allow_mutation = !(command.dry_run || command.json); + let stale_paths: Vec = + remote_worktree_helper(&client, args, allow_mutation).await?; + 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 { + println!("removed {}", path.display()); + } + } + } + } + WorktreeSubcommand::Internal(_) => { + anyhow::bail!( + "internal worktree helpers are executed on the app-server side and cannot be proxied" + ); + } + } + Ok(()) +} + +async fn connect_remote_app_server_for_worktree( + remote: Option, + remote_auth_token_env: Option, +) -> anyhow::Result { + let remote = remote.context("`--remote-auth-token-env` requires `--remote`")?; + let websocket_url = + codex_tui::normalize_remote_addr(&remote).map_err(|err| anyhow::anyhow!("{err}"))?; + let auth_token = remote_auth_token_env + .as_deref() + .map(read_remote_auth_token_from_env_var) + .transpose()?; + RemoteAppServerClient::connect(RemoteAppServerConnectArgs { + websocket_url, + auth_token, + client_name: "codex-cli".to_string(), + client_version: env!("CARGO_PKG_VERSION").to_string(), + experimental_api: true, + opt_out_notification_methods: Vec::new(), + channel_capacity: codex_app_server_client::DEFAULT_IN_PROCESS_CHANNEL_CAPACITY, + }) + .await + .map_err(anyhow::Error::from) +} + +async fn remote_worktree_helper( + client: &RemoteAppServerClient, + args: Vec, + allow_mutation: bool, +) -> anyhow::Result +where + T: DeserializeOwned, +{ + let mut command = vec![ + "codex".to_string(), + "worktree".to_string(), + "__internal".to_string(), + ]; + command.extend(args); + let mut env = HashMap::new(); + env.insert( + codex_arg0::CODEX_ARG0_SKIP_PATH_UPDATE_ENV_VAR.to_string(), + Some("1".to_string()), + ); + let response: CommandExecResponse = client + .request_typed(ClientRequest::OneOffCommandExec { + request_id: RequestId::String("cli-worktree-helper".to_string()), + params: CommandExecParams { + command, + use_codex_self_exe: true, + process_id: None, + tty: false, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: None, + disable_output_cap: true, + disable_timeout: false, + timeout_ms: Some(if allow_mutation { 120_000 } else { 30_000 }), + cwd: None, + env: Some(env), + size: None, + sandbox_policy: allow_mutation.then_some(SandboxPolicy::DangerFullAccess), + permission_profile: None, + }, + }) + .await?; + if response.exit_code != 0 { + let detail = if response.stderr.trim().is_empty() { + response.stdout.trim() + } else { + response.stderr.trim() + }; + if detail.is_empty() { + anyhow::bail!( + "worktree helper failed with exit code {}", + response.exit_code + ); + } + anyhow::bail!("{detail}"); + } + serde_json::from_str(response.stdout.trim()).context("failed to parse worktree helper output") +} + +fn worktree_create_helper_args( + branch: String, + base_ref: Option, + dirty_policy: WorktreeDirtyCliArg, +) -> Vec { + let mut args = vec![ + "create".to_string(), + branch, + "--dirty".to_string(), + worktree_dirty_policy_arg(dirty_policy).to_string(), + ]; + if let Some(base_ref) = base_ref { + args.push("--base".to_string()); + args.push(base_ref); + } + args +} + +fn worktree_remove_helper_args(target: String, force: bool, delete_branch: bool) -> Vec { + let mut args = vec!["remove".to_string(), target]; + if force { + args.push("--force".to_string()); + } + if delete_branch { + args.push("--delete-branch".to_string()); + } + args +} + +fn worktree_dirty_policy_arg(arg: WorktreeDirtyCliArg) -> &'static str { + match arg { + WorktreeDirtyCliArg::Fail => "fail", + WorktreeDirtyCliArg::Ignore => "ignore", + WorktreeDirtyCliArg::CopyTracked => "copy-tracked", + WorktreeDirtyCliArg::CopyAll => "copy-all", + WorktreeDirtyCliArg::MoveTracked => "move-tracked", + WorktreeDirtyCliArg::MoveAll => "move-all", + } +} + +fn print_json(value: &impl Serialize) -> anyhow::Result<()> { + println!("{}", serde_json::to_string(value)?); + 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`. @@ -821,6 +1509,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(), @@ -839,6 +1532,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(), @@ -1033,6 +1731,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()), @@ -1059,6 +1765,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()), @@ -1070,6 +1784,14 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { .await?; handle_app_exit(exit_info)?; } + Some(Subcommand::Worktree(worktree_cli)) => { + run_worktree_command( + worktree_cli, + root_remote.clone(), + root_remote_auth_token_env.clone(), + ) + .await?; + } Some(Subcommand::Login(mut login_cli)) => { reject_remote_mode_for_subcommand( root_remote.as_deref(), @@ -2164,6 +2886,85 @@ 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 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_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"]) + .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/src/lib.rs b/codex-rs/exec/src/lib.rs index e68c96d00b..22b382287c 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -266,6 +266,9 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result dangerously_bypass_approvals_and_sandbox, cwd, add_dir, + worktree: _, + worktree_base: _, + worktree_dirty: _, } = shared; let (_stdout_with_ansi, stderr_with_ansi) = match color { diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index d3d28461b6..d769f36a30 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -56,6 +56,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 4cd3d62cbd..901bd24b76 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; @@ -871,6 +872,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/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 565ee6ba1f..86f897c740 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -187,6 +187,67 @@ impl App { tui.frame_requester().schedule_frame(); } + AppEvent::OpenWorktreePicker => { + self.open_worktree_picker(tui, app_server); + tui.frame_requester().schedule_frame(); + } + AppEvent::WorktreesLoaded { cwd, result } => { + self.on_worktrees_loaded(cwd, result); + tui.frame_requester().schedule_frame(); + } + AppEvent::OpenWorktreeCreatePrompt => { + self.open_worktree_create_prompt(); + 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::WorktreeCreated { cwd, result } => { + self.on_worktree_created(tui, app_server, cwd, result).await; + tui.frame_requester().schedule_frame(); + } + AppEvent::SwitchToWorktree { 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 { info } => { + self.switch_to_worktree_info_after_loading(tui, app_server, info) + .await; + tui.frame_requester().schedule_frame(); + } + AppEvent::WorktreeSessionReady(event) => { + self.on_worktree_session_ready(tui, app_server, event).await; + tui.frame_requester().schedule_frame(); + } + AppEvent::ShowWorktreePath { target } => { + self.show_worktree_path(app_server, target).await; + tui.frame_requester().schedule_frame(); + } + AppEvent::RemoveWorktree { + target, + force, + delete_branch, + confirmed, + } => { + self.remove_worktree(app_server, target, force, delete_branch, confirmed) + .await; + tui.frame_requester().schedule_frame(); + } AppEvent::BeginInitialHistoryReplayBuffer => { self.begin_initial_history_replay_buffer(); } diff --git a/codex-rs/tui/src/app/startup_prompts.rs b/codex-rs/tui/src/app/startup_prompts.rs index 41972e6751..782c41c171 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 should_show_model_migration_prompt( current_model: &str, target_model: &str, diff --git a/codex-rs/tui/src/app/thread_routing.rs b/codex-rs/tui/src/app/thread_routing.rs index f25398b0b8..a91b3ca825 100644 --- a/codex-rs/tui/src/app/thread_routing.rs +++ b/codex-rs/tui/src/app/thread_routing.rs @@ -1011,6 +1011,9 @@ impl App { turns: Vec, ) -> Result<()> { let thread_id = session.thread_id; + let thread_id_string = thread_id.to_string(); + self.bind_current_worktree_thread_best_effort(session.cwd.as_path(), &thread_id_string) + .await; self.primary_thread_id = Some(thread_id); self.primary_session_configured = Some(session.clone()); self.upsert_agent_picker_thread( diff --git a/codex-rs/tui/src/app/worktree.rs b/codex-rs/tui/src/app/worktree.rs new file mode 100644 index 0000000000..a6923cb5ad --- /dev/null +++ b/codex-rs/tui/src/app/worktree.rs @@ -0,0 +1,1141 @@ +//! App-layer handlers for the worktree TUI flow. + +use anyhow::Context; +use codex_app_server_protocol::SandboxPolicy; +use codex_protocol::ThreadId; +use codex_worktree::DirtyPolicy; +use codex_worktree::WorktreeInfo; +use codex_worktree::WorktreeRemoveResult; +use codex_worktree::WorktreeResolution; +use codex_worktree::WorktreeSource; +use serde::de::DeserializeOwned; +use std::path::Path; +use std::path::PathBuf; +use std::time::Duration; + +use super::*; +use crate::app_event::WorktreeSessionReadyEvent; +use crate::bottom_pane::custom_prompt_view::CustomPromptView; +use crate::workspace_command::WorkspaceCommand; +use crate::workspace_command::WorkspaceCommandRunner; + +const WORKTREE_SWITCH_RENDER_DELAY: Duration = Duration::from_millis(20); +const WORKTREE_READ_TIMEOUT: Duration = Duration::from_secs(30); +const WORKTREE_MUTATION_TIMEOUT: Duration = Duration::from_secs(120); + +#[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, + app_server: &AppServerSession, + ) { + self.chat_widget + .show_selection_view(crate::worktree::loading_params( + tui.frame_requester(), + self.config.animations, + )); + self.spawn_worktree_list_request(self.session_workspace_cwd(app_server).to_path_buf()); + } + + pub(super) fn open_worktree_create_prompt(&mut self) { + 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, + 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, + result: Result, String>, + ) { + 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, 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: &AppServerSession, + branch: String, + base_ref: 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() => { + 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; + } + }, + }; + + self.show_worktree_creating_view(tui, branch.clone()); + self.spawn_worktree_create_request( + self.session_workspace_cwd(app_server).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.session_workspace_cwd(app_server) { + return; + } + let resolution = match result { + Ok(resolution) => resolution, + Err(err) => { + self.show_worktree_error("Failed to create worktree.".to_string(), err); + return; + } + }; + 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, + resolution + .warnings + .into_iter() + .map(|warning| warning.message) + .collect(), + ) + .await; + } + + pub(super) async fn begin_switch_to_worktree_target( + &mut self, + tui: &mut tui::Tui, + app_server: &AppServerSession, + target: String, + ) { + self.show_worktree_switching_view(tui, target.clone()); + 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()); + 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.defer_switch_to_worktree_info(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; + } + + 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( + 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) async fn remove_worktree( + &mut self, + app_server: &AppServerSession, + target: String, + force: bool, + delete_branch: bool, + confirmed: bool, + ) { + let entries = match self + .list_current_repo_worktrees_for_session(app_server) + .await + { + 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; + } + + let result: anyhow::Result = self + .run_worktree_helper( + self.session_workspace_cwd(app_server), + worktree_remove_helper_args(target.clone(), force, delete_branch), + /*allow_mutation*/ true, + ) + .await; + match result { + 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}")); + } + } + } + + async fn list_current_repo_worktrees_for_session( + &self, + app_server: &AppServerSession, + ) -> anyhow::Result> { + self.run_worktree_helper( + self.session_workspace_cwd(app_server), + vec!["list".to_string()], + /*allow_mutation*/ false, + ) + .await + } + + async fn source_worktree_dirty_state( + &self, + app_server: &AppServerSession, + ) -> anyhow::Result { + self.run_worktree_helper( + self.session_workspace_cwd(app_server), + vec!["inspect-source".to_string()], + /*allow_mutation*/ false, + ) + .await + } + + fn spawn_worktree_list_request(&self, cwd: PathBuf) { + let app_event_tx = self.app_event_tx.clone(); + let Some(runner) = self.workspace_command_runner.clone() else { + app_event_tx.send(AppEvent::WorktreesLoaded { + cwd, + result: Err("worktree operations require a workspace command runner".to_string()), + }); + return; + }; + + tokio::spawn(async move { + let result = run_worktree_helper_with::>( + runner, + cwd.clone(), + vec!["list".to_string()], + /*allow_mutation*/ false, + ) + .await + .map_err(|err| err.to_string()); + app_event_tx.send(AppEvent::WorktreesLoaded { cwd, result }); + }); + } + + 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 spawn_worktree_create_request( + &self, + cwd: PathBuf, + branch: String, + base_ref: Option, + dirty_policy: DirtyPolicy, + ) { + let app_event_tx = self.app_event_tx.clone(); + let Some(runner) = self.workspace_command_runner.clone() else { + app_event_tx.send(AppEvent::WorktreeCreated { + cwd, + result: Err("worktree operations require a workspace command runner".to_string()), + }); + return; + }; + let args = worktree_create_helper_args(branch, base_ref, dirty_policy); + tokio::spawn(async move { + let result = run_worktree_helper_with::( + runner, + cwd.clone(), + args, + /*allow_mutation*/ true, + ) + .await + .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 = 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); + + let mode = self.worktree_switch_mode().await; + self.spawn_worktree_session_request(app_server, info, config, mode, warnings); + tui.frame_requester().schedule_frame(); + } + + pub(super) async fn on_worktree_session_ready( + &mut self, + tui: &mut tui::Tui, + app_server: &mut AppServerSession, + event: WorktreeSessionReadyEvent, + ) { + let WorktreeSessionReadyEvent { + info, + config, + forked, + warnings, + result, + } = event; + match result { + Ok(started) => { + let thread_id = started.session.thread_id.to_string(); + 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 attach to worktree thread.".to_string(), + err.to_string(), + ); + } else { + if app_server.is_remote() { + app_server.set_remote_cwd_override(Some(info.workspace_cwd.clone())); + } + if let Err(err) = self + .bind_worktree_thread(info.workspace_cwd.as_path(), &thread_id) + .await + { + tracing::warn!(?err, "failed to bind managed worktree to thread"); + } + let transition = if forked { + WorktreeSessionTransition::Forked + } else { + WorktreeSessionTransition::Started + }; + self.add_worktree_session_message(&info, transition); + for warning in warnings { + self.chat_widget.add_info_message(warning, /*hint*/ None); + } + } + } + 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, + warnings: Vec, + ) { + let request_handle = app_server.request_handle(); + 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(_)); + 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 + } + 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(WorktreeSessionReadyEvent { + info, + config, + forked, + warnings, + 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 run_worktree_helper( + &self, + cwd: &Path, + args: Vec, + allow_mutation: bool, + ) -> anyhow::Result + where + T: DeserializeOwned, + { + let runner = self + .workspace_command_runner + .clone() + .context("worktree operations require a workspace command runner")?; + run_worktree_helper_with(runner, cwd.to_path_buf(), args, allow_mutation).await + } + + async fn bind_worktree_thread(&self, cwd: &Path, thread_id: &str) -> anyhow::Result<()> { + let _: serde_json::Value = self + .run_worktree_helper( + cwd, + vec!["bind-thread".to_string(), thread_id.to_string()], + /*allow_mutation*/ true, + ) + .await?; + Ok(()) + } + + pub(super) async fn bind_current_worktree_thread_best_effort( + &self, + cwd: &Path, + thread_id: &str, + ) { + match self + .run_worktree_helper::>( + cwd, + vec!["resolve-current".to_string()], + /*allow_mutation*/ false, + ) + .await + { + Ok(Some(_)) => { + if let Err(err) = self.bind_worktree_thread(cwd, thread_id).await { + tracing::warn!(?err, "failed to bind managed worktree to thread"); + } + } + Ok(None) => {} + Err(err) => { + tracing::warn!(?err, "failed to resolve managed worktree metadata"); + } + } + } + + 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 + } + } + + 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) { + 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::creating_params( + branch, + tui.frame_requester(), + self.config.animations, + )); + tui.frame_requester().schedule_frame(); + } + + 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 { info }); + }); + } + + 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()); + } +} + +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(), + ) +} + +async fn run_worktree_helper_with( + runner: WorkspaceCommandRunner, + cwd: PathBuf, + args: Vec, + allow_mutation: bool, +) -> anyhow::Result +where + T: DeserializeOwned, +{ + let mut command = WorkspaceCommand::codex_self(["worktree", "__internal"]) + .cwd(cwd) + .env(codex_arg0::CODEX_ARG0_SKIP_PATH_UPDATE_ENV_VAR, "1") + .disable_output_cap() + .timeout(if allow_mutation { + WORKTREE_MUTATION_TIMEOUT + } else { + WORKTREE_READ_TIMEOUT + }); + command.argv.extend(args); + if allow_mutation { + command = command.sandbox_policy(SandboxPolicy::DangerFullAccess); + } + let output = runner.run(command).await?; + if !output.success() { + let detail = if output.stderr.trim().is_empty() { + output.stdout.trim() + } else { + output.stderr.trim() + }; + if detail.is_empty() { + if output.exit_code == 124 { + anyhow::bail!("Timed out while loading worktrees."); + } + anyhow::bail!("worktree helper failed with exit code {}", output.exit_code); + } + anyhow::bail!("{detail}"); + } + anyhow::Context::context( + serde_json::from_str(output.stdout.trim()), + "failed to parse worktree helper output", + ) +} + +fn worktree_create_helper_args( + branch: String, + base_ref: Option, + dirty_policy: DirtyPolicy, +) -> Vec { + let mut args = vec!["create".to_string(), branch, "--dirty".to_string()]; + args.push(worktree_dirty_policy_arg(dirty_policy).to_string()); + if let Some(base_ref) = base_ref { + args.push("--base".to_string()); + args.push(base_ref); + } + args +} + +fn worktree_remove_helper_args(target: String, force: bool, delete_branch: bool) -> Vec { + let mut args = vec!["remove".to_string(), target]; + if force { + args.push("--force".to_string()); + } + if delete_branch { + args.push("--delete-branch".to_string()); + } + args +} + +fn worktree_dirty_policy_arg(policy: DirtyPolicy) -> &'static str { + match policy { + DirtyPolicy::Fail => "fail", + DirtyPolicy::Ignore => "ignore", + DirtyPolicy::CopyTracked => "copy-tracked", + DirtyPolicy::CopyAll => "copy-all", + DirtyPolicy::MoveTracked => "move-tracked", + DirtyPolicy::MoveAll => "move-all", + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::workspace_command::WorkspaceCommandError; + use crate::workspace_command::WorkspaceCommandExecutor; + use crate::workspace_command::WorkspaceCommandOutput; + 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 serde_json::json; + use std::collections::VecDeque; + use std::future::Future; + use std::pin::Pin; + use std::sync::Arc; + use std::sync::Mutex; + use tempfile::TempDir; + + #[test] + fn worktree_create_helper_args_include_dirty_policy_and_base() { + assert_eq!( + worktree_create_helper_args( + "fcoury/demo".to_string(), + Some("origin/main".to_string()), + DirtyPolicy::MoveAll, + ), + vec![ + "create".to_string(), + "fcoury/demo".to_string(), + "--dirty".to_string(), + "move-all".to_string(), + "--base".to_string(), + "origin/main".to_string(), + ] + ); + } + + #[test] + fn worktree_remove_helper_args_include_selected_flags() { + assert_eq!( + worktree_remove_helper_args( + "fcoury/demo".to_string(), + /*force*/ true, + /*delete_branch*/ true, + ), + vec![ + "remove".to_string(), + "fcoury/demo".to_string(), + "--force".to_string(), + "--delete-branch".to_string(), + ] + ); + } + + #[tokio::test] + async fn worktree_helper_runs_hidden_codex_command_in_workspace() { + let cwd = PathBuf::from("/repo/codex"); + let runner = Arc::new(FakeRunner::new(vec![WorkspaceCommandOutput { + exit_code: 0, + stdout: r#"{"ok":true}"#.to_string(), + stderr: String::new(), + }])); + let output = run_worktree_helper_with::( + runner.clone(), + cwd.clone(), + vec!["list".to_string()], + /*allow_mutation*/ false, + ) + .await + .expect("helper output"); + + assert_eq!(output, json!({"ok": true})); + let commands = runner.commands(); + assert_eq!(commands.len(), 1); + assert_eq!( + commands[0].argv, + vec![ + "codex".to_string(), + "worktree".to_string(), + "__internal".to_string(), + "list".to_string(), + ] + ); + assert_eq!(commands[0].cwd.as_deref(), Some(cwd.as_path())); + assert_eq!( + commands[0] + .env + .get(codex_arg0::CODEX_ARG0_SKIP_PATH_UPDATE_ENV_VAR), + Some(&Some("1".to_string())) + ); + assert!(commands[0].disable_output_cap); + assert_eq!(commands[0].timeout, WORKTREE_READ_TIMEOUT); + assert_eq!(commands[0].sandbox_policy, None); + } + + #[tokio::test] + async fn worktree_helper_reports_timeout_without_raw_exit_code() { + let runner = Arc::new(FakeRunner::new(vec![WorkspaceCommandOutput { + exit_code: 124, + stdout: String::new(), + stderr: String::new(), + }])); + + let error = run_worktree_helper_with::( + runner, + PathBuf::from("/repo/codex"), + vec!["list".to_string()], + /*allow_mutation*/ false, + ) + .await + .expect_err("timeout should be reported as an error"); + + assert_eq!(error.to_string(), "Timed out while loading worktrees."); + } + + #[tokio::test] + async fn mutating_worktree_helper_disables_sandbox() { + let runner = Arc::new(FakeRunner::new(vec![WorkspaceCommandOutput { + exit_code: 0, + stdout: r#"{"ok":true}"#.to_string(), + stderr: String::new(), + }])); + + run_worktree_helper_with::( + runner.clone(), + PathBuf::from("/repo/codex"), + vec!["remove".to_string(), "fcoury/demo".to_string()], + /*allow_mutation*/ true, + ) + .await + .expect("helper output"); + + assert_eq!( + runner.commands()[0].sandbox_policy, + Some(SandboxPolicy::DangerFullAccess) + ); + assert_eq!(runner.commands()[0].timeout, WORKTREE_MUTATION_TIMEOUT); + } + + #[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), + } + } + + struct FakeRunner { + outputs: Mutex>, + commands: Mutex>, + } + + impl FakeRunner { + fn new(outputs: Vec) -> Self { + Self { + outputs: Mutex::new(outputs.into()), + commands: Mutex::new(Vec::new()), + } + } + + fn commands(&self) -> Vec { + self.commands.lock().expect("commands lock").clone() + } + } + + impl WorkspaceCommandExecutor for FakeRunner { + fn run( + &self, + command: WorkspaceCommand, + ) -> Pin< + Box< + dyn Future> + + Send + + '_, + >, + > { + Box::pin(async move { + self.commands.lock().expect("commands lock").push(command); + Ok(self + .outputs + .lock() + .expect("outputs lock") + .pop_front() + .expect("missing fake output")) + }) + } + } + + 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 664ddf12d8..e429ae6fa5 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -31,12 +31,16 @@ 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 codex_worktree::WorktreeInfo; 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; @@ -69,6 +73,15 @@ pub(crate) struct HistoryLookupResponse { pub(crate) entry: Option, } +#[derive(Debug)] +pub(crate) struct WorktreeSessionReadyEvent { + pub(crate) info: WorktreeInfo, + pub(crate) config: Config, + pub(crate) forked: bool, + pub(crate) warnings: Vec, + pub(crate) result: Result, +} + impl RealtimeAudioDeviceKind { pub(crate) fn title(self) -> &'static str { match self { @@ -190,6 +203,67 @@ 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 picker. + WorktreesLoaded { + cwd: PathBuf, + result: Result, String>, + }, + + /// Open the prompt for creating a managed worktree. + OpenWorktreeCreatePrompt, + + /// Create or reuse a managed worktree and switch the TUI into it. + CreateWorktreeAndSwitch { + branch: String, + base_ref: Option, + 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, + }, + + /// Switch the TUI into a picker-selected existing worktree. + SwitchToWorktreeInfo { + info: WorktreeInfo, + }, + + /// 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 { + info: WorktreeInfo, + }, + + /// Result of starting or forking a session in a worktree. + WorktreeSessionReady(WorktreeSessionReadyEvent), + + /// 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/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 9f1e0f8101..b44b294d35 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::Result; use color_eyre::eyre::WrapErr; use std::collections::HashMap; 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, @@ -189,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(_)) } @@ -969,6 +975,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.rs b/codex-rs/tui/src/chatwidget.rs index 159c65f011..d65ced0b8a 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -5166,6 +5166,30 @@ 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, + 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 302480c86d..0ff67e665f 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -171,6 +171,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() { @@ -765,6 +768,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 }); @@ -930,6 +940,7 @@ impl ChatWidget { | SlashCommand::Clear | SlashCommand::Resume | SlashCommand::Fork + | SlashCommand::Worktree | SlashCommand::Init | SlashCommand::Compact | SlashCommand::Review diff --git a/codex-rs/tui/src/chatwidget/status_surfaces.rs b/codex-rs/tui/src/chatwidget/status_surfaces.rs index 12ed07cf98..a9c49437dc 100644 --- a/codex-rs/tui/src/chatwidget/status_surfaces.rs +++ b/codex-rs/tui/src/chatwidget/status_surfaces.rs @@ -7,6 +7,8 @@ use super::*; use crate::bottom_pane::status_line_from_segments; use crate::branch_summary; use crate::legacy_core::config::Config; +use crate::motion::ACTIVITY_SPINNER_INTERVAL; +use crate::motion::activity_spinner_frame_at; use crate::status::format_tokens_compact; use codex_app_server_protocol::AskForApproval; use codex_protocol::config_types::ApprovalsReviewer; @@ -20,13 +22,6 @@ use super::status_state::TerminalTitleStatusKind; /// 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); @@ -357,7 +352,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) { @@ -819,14 +814,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/lib.rs b/codex-rs/tui/src/lib.rs index 0e507dd783..74085a87e9 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -29,6 +29,7 @@ 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::ConfigWarningNotification; +use codex_app_server_protocol::SandboxPolicy; use codex_app_server_protocol::Thread as AppServerThread; use codex_app_server_protocol::ThreadListCwdFilter; use codex_app_server_protocol::ThreadListParams; @@ -54,6 +55,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; @@ -62,6 +64,7 @@ use std::fs::OpenOptions; use std::path::Path; use std::path::PathBuf; use std::sync::Arc; +use std::time::Duration; pub use token_usage::TokenUsage; use tracing::Level; use tracing::error; @@ -72,6 +75,9 @@ use tracing_subscriber::filter::Targets; use tracing_subscriber::prelude::*; use url::Url; use uuid::Uuid; +use workspace_command::AppServerWorkspaceCommandRunner; +use workspace_command::WorkspaceCommand; +use workspace_command::WorkspaceCommandExecutor; pub(crate) use codex_app_server_client::legacy_core; @@ -180,6 +186,8 @@ 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; mod update_prompt; @@ -706,6 +714,77 @@ fn latest_session_cwd_filter<'a>( } } +async fn resolve_remote_startup_worktree( + cli: &mut Cli, + app_server: &mut AppServerSession, + remote_cwd_override: &mut Option, +) -> color_eyre::Result<()> { + let Some(branch) = cli.worktree.take() else { + return Ok(()); + }; + + let mut command = WorkspaceCommand::codex_self([ + "worktree".to_string(), + "__internal".to_string(), + "create".to_string(), + branch, + "--dirty".to_string(), + worktree_dirty_policy_arg(cli.worktree_dirty).to_string(), + ]) + .disable_output_cap() + .sandbox_policy(SandboxPolicy::DangerFullAccess) + .timeout(Duration::from_secs(/*secs*/ 120)); + if let Some(base_ref) = cli.worktree_base.take() { + command.argv.push("--base".to_string()); + command.argv.push(base_ref); + } + if let Some(cwd) = remote_cwd_override.clone() { + command = command.cwd(cwd); + } + + let runner = AppServerWorkspaceCommandRunner::new(app_server.request_handle()); + let output = runner + .run(command) + .await + .map_err(|err| color_eyre::eyre::eyre!("failed to run remote worktree helper: {err}"))?; + if !output.success() { + let detail = if output.stderr.trim().is_empty() { + output.stdout.trim() + } else { + output.stderr.trim() + }; + if detail.is_empty() { + color_eyre::eyre::bail!( + "remote worktree helper failed with exit code {}", + output.exit_code + ); + } + color_eyre::eyre::bail!("{detail}"); + } + + let resolution: codex_worktree::WorktreeResolution = serde_json::from_str(output.stdout.trim()) + .wrap_err("failed to parse remote worktree helper output")?; + let workspace_cwd = resolution.info.workspace_cwd; + *remote_cwd_override = Some(workspace_cwd.clone()); + app_server.set_remote_cwd_override(Some(workspace_cwd.clone())); + cli.cwd = Some(workspace_cwd); + for warning in resolution.warnings { + tracing::warn!(message = %warning.message, "remote worktree startup warning"); + } + Ok(()) +} + +fn worktree_dirty_policy_arg(arg: WorktreeDirtyCliArg) -> &'static str { + match arg { + WorktreeDirtyCliArg::Fail => "fail", + WorktreeDirtyCliArg::Ignore => "ignore", + WorktreeDirtyCliArg::CopyTracked => "copy-tracked", + WorktreeDirtyCliArg::CopyAll => "copy-all", + WorktreeDirtyCliArg::MoveTracked => "move-tracked", + WorktreeDirtyCliArg::MoveAll => "move-all", + } +} + pub async fn run_main( mut cli: Cli, arg0_paths: Arg0DispatchPaths, @@ -1099,11 +1178,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)>, @@ -1185,6 +1264,14 @@ async fn run_ratatui_app( }, ); + if remote_mode { + let Some(startup_app_server) = app_server.as_mut() else { + unreachable!("app server should be initialized for remote --worktree"); + }; + resolve_remote_startup_worktree(&mut cli, startup_app_server, &mut remote_cwd_override) + .await?; + } + 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 { diff --git a/codex-rs/tui/src/motion.rs b/codex-rs/tui/src/motion.rs index bb137ca653..4e6fa0e999 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/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index 66e26d9778..b6bdec40db 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/slash_command.rs b/codex-rs/tui/src/slash_command.rs index cce0bc38df..2714ce1284 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -32,6 +32,7 @@ pub enum SlashCommand { New, Resume, Fork, + Worktree, Init, Compact, Plan, @@ -86,6 +87,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", @@ -153,6 +155,7 @@ impl SlashCommand { | SlashCommand::Raw | SlashCommand::Side | SlashCommand::Resume + | SlashCommand::Worktree | SlashCommand::SandboxReadRoot ) } @@ -176,6 +179,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_creating.snap b/codex-rs/tui/src/snapshots/codex_tui__worktree__tests__worktree_creating.snap new file mode 100644 index 0000000000..8a4f46bb5d --- /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_dirty_policy_prompt.snap b/codex-rs/tui/src/snapshots/codex_tui__worktree__tests__worktree_dirty_policy_prompt.snap new file mode 100644 index 0000000000..8e3ee838fa --- /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 pending changes + Move them to the new worktree or leave them here. + +› 1. Move changes Move tracked changes and untracked files; leave the + source checkout clean. + 2. Leave changes here Create the worktree and keep pending changes in this + checkout. + + 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 0000000000..8219177c4f --- /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_loading.snap b/codex-rs/tui/src/snapshots/codex_tui__worktree__tests__worktree_loading.snap new file mode 100644 index 0000000000..ace4a3f023 --- /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 0000000000..be9827b7b9 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__worktree__tests__worktree_picker.snap @@ -0,0 +1,15 @@ +--- +source: tui/src/worktree.rs +expression: "render_selection(params, 86)" +--- + + Worktrees + Create a worktree or fork this chat into an existing workspace. + + Search worktrees + 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 + + 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 0000000000..59892b7a45 --- /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 0000000000..11ad971539 --- /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/workspace_command.rs b/codex-rs/tui/src/workspace_command.rs index c6b2e770e6..5dc806da3d 100644 --- a/codex-rs/tui/src/workspace_command.rs +++ b/codex-rs/tui/src/workspace_command.rs @@ -22,6 +22,7 @@ use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::CommandExecParams; use codex_app_server_protocol::CommandExecResponse; use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::SandboxPolicy; use uuid::Uuid; /// Shared handle for running workspace commands from TUI components. @@ -47,6 +48,10 @@ pub(crate) struct WorkspaceCommand { pub(crate) output_bytes_cap: usize, /// Whether app-server should return uncapped stdout/stderr. pub(crate) disable_output_cap: bool, + /// Optional sandbox override for this internal workspace command. + pub(crate) sandbox_policy: Option, + /// Whether app-server should replace argv[0] with its own Codex executable. + pub(crate) use_codex_self_exe: bool, } impl WorkspaceCommand { @@ -59,6 +64,18 @@ impl WorkspaceCommand { timeout: Duration::from_secs(/*secs*/ 5), output_bytes_cap: 64 * 1024, disable_output_cap: false, + sandbox_policy: None, + use_codex_self_exe: false, + } + } + + /// Creates a command that runs the app-server's own Codex executable. + pub(crate) fn codex_self(args: impl IntoIterator>) -> Self { + let mut argv = vec!["codex".to_string()]; + argv.extend(args.into_iter().map(Into::into)); + Self { + use_codex_self_exe: true, + ..Self::new(argv) } } @@ -85,6 +102,12 @@ impl WorkspaceCommand { self.disable_output_cap = true; self } + + /// Overrides the session sandbox for this internal workspace command. + pub(crate) fn sandbox_policy(mut self, sandbox_policy: SandboxPolicy) -> Self { + self.sandbox_policy = Some(sandbox_policy); + self + } } /// Captured result from a completed workspace command. @@ -187,6 +210,7 @@ impl WorkspaceCommandExecutor for AppServerWorkspaceCommandRunner { request_id: RequestId::String(format!("workspace-command-{}", Uuid::new_v4())), params: CommandExecParams { command: command.argv, + use_codex_self_exe: command.use_codex_self_exe, process_id: None, tty: false, stream_stdin: false, @@ -199,7 +223,7 @@ impl WorkspaceCommandExecutor for AppServerWorkspaceCommandRunner { cwd: command.cwd, env, size: None, - sandbox_policy: None, + sandbox_policy: command.sandbox_policy, permission_profile: None, }, }) diff --git a/codex-rs/tui/src/worktree.rs b/codex-rs/tui/src/worktree.rs new file mode 100644 index 0000000000..6abc3be0b6 --- /dev/null +++ b/codex-rs/tui/src/worktree.rs @@ -0,0 +1,899 @@ +use std::path::Path; +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::SelectionAction; +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::ACTIVITY_SPINNER_INTERVAL; +use crate::motion::activity_spinner_frame_at; +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"; + +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; + } + + if self.animations_enabled { + self.frame_requester + .schedule_frame_in(ACTIVITY_SPINNER_INTERVAL); + } + + let mut loading_spans = Vec::new(); + 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()); + + 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), + "move-tracked" => Ok(DirtyPolicy::MoveTracked), + "move-all" => Ok(DirtyPolicy::MoveAll), + _ => Err( + "Dirty mode must be one of: fail, ignore, copy-tracked, copy-all, move-tracked, move-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 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())); + + SelectionViewParams { + view_id: Some(WORKTREE_SELECTION_VIEW_ID), + header: Box::new(header), + footer_hint: Some(standard_popup_hint_line()), + items: vec![new_worktree_item()], + ..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 mut items = vec![new_worktree_item()]; + 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() { + "dirty" + } 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, + entry.name, + source, + entry.workspace_cwd.display() + )); + let actions: Vec = if is_current { + let target_for_action = target.clone(); + vec![Box::new(move |tx| { + tx.send(AppEvent::CurrentWorktreeSelected { + target: target_for_action.clone(), + }); + })] + } else { + let info_for_action = entry; + vec![Box::new(move |tx| { + tx.send(AppEvent::SwitchToWorktreeInfo { + info: info_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() + } + })); + + let mut header = ColumnRenderable::new(); + header.push(Line::from("Worktrees".bold())); + header.push(Line::from( + "Create a worktree or fork this chat into an existing 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, + initial_selected_idx, + ..Default::default() + } +} + +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, +) -> SelectionViewParams { + let mut header = ColumnRenderable::new(); + header.push(Line::from("Source checkout has pending changes".bold())); + header.push(Line::from( + "Move them to the new worktree or leave them here.".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( + "Move changes", + "Move tracked changes and untracked files; leave the source checkout clean.", + DirtyPolicy::MoveAll, + ), + item( + "Leave changes here", + "Create the worktree and keep pending changes in this checkout.", + DirtyPolicy::Ignore, + ), + ], + ..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 +} + +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::*; + 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_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_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!( + 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_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!( + "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_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!( + "worktree_dirty_policy_prompt", + render_selection( + dirty_policy_prompt_params("fcoury/demo".to_string(), /*base_ref*/ None), + /*width*/ 82 + ) + ); + } + + #[test] + fn dirty_policy_prompt_dispatches_move_all_or_ignore() { + let (tx_raw, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let params = + dirty_policy_prompt_params("fcoury/demo".to_string(), Some("origin/main".to_string())); + + assert_eq!(params.items.len(), 2); + + (params.items[0].actions[0])(&tx); + assert!(matches!( + rx.try_recv(), + Ok(AppEvent::CreateWorktreeAndSwitch { + branch, + base_ref, + dirty_policy: Some(DirtyPolicy::MoveAll), + }) if branch == "fcoury/demo" && base_ref.as_deref() == Some("origin/main") + )); + + (params.items[1].actions[0])(&tx); + assert!(matches!( + rx.try_recv(), + Ok(AppEvent::CreateWorktreeAndSwitch { + branch, + base_ref, + dirty_policy: Some(DirtyPolicy::Ignore), + }) if branch == "fcoury/demo" && base_ref.as_deref() == Some("origin/main") + )); + } + + #[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/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..cf90771433 --- /dev/null +++ b/codex-rs/utils/cli/src/worktree_dirty_cli_arg.rs @@ -0,0 +1,13 @@ +use clap::ValueEnum; + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, ValueEnum)] +#[value(rename_all = "kebab-case")] +pub enum WorktreeDirtyCliArg { + #[default] + Fail, + Ignore, + CopyTracked, + CopyAll, + MoveTracked, + MoveAll, +} 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..0491b8c1c9 --- /dev/null +++ b/codex-rs/worktree/src/dirty.rs @@ -0,0 +1,303 @@ +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, + MoveTracked, + MoveAll, +} + +#[derive(Debug)] +struct TransferPlan { + staged_diff: Vec, + unstaged_diff: Vec, + tracked_paths: Vec, + 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 { + 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 | 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(), + ]) + } else { + Ok(Vec::new()) + } + } + DirtyPolicy::CopyAll | DirtyPolicy::MoveAll => Ok(Vec::new()), + } +} + +pub(crate) fn prepare_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(PreparedDirtyTransfer { move_plan: None }); + } + + if matches!(policy, DirtyPolicy::Fail | DirtyPolicy::Ignore) { + return Ok(PreparedDirtyTransfer { move_plan: None }); + } + + let plan = TransferPlan::capture(source_root)?; + plan.apply_tracked_diff(worktree_root)?; + + let move_untracked = matches!(policy, DirtyPolicy::MoveAll); + if matches!(policy, DirtyPolicy::CopyAll | DirtyPolicy::MoveAll) { + copy_untracked_files_at_paths(source_root, worktree_root, &plan.untracked_paths)?; + } + let move_plan = if matches!(policy, DirtyPolicy::MoveTracked | DirtyPolicy::MoveAll) { + Some(MovePlan { + transfer: plan, + move_untracked, + }) + } else { + None + }; + Ok(PreparedDirtyTransfer { move_plan }) +} + +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" + ); +} + +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)?, + }) + } + + 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(()) + } +} + +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 paths_from_nul_separated(output: &[u8]) -> Result> { + output + .split(|byte| *byte == 0) + .filter(|path| !path.is_empty()) + .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 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, + 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)?; + } + 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..1c01488d2b --- /dev/null +++ b/codex-rs/worktree/src/lib.rs @@ -0,0 +1,111 @@ +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 dirty::dirty_state; +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 { + pub codex_home: PathBuf, + pub source_cwd: PathBuf, + pub branch: String, + pub base_ref: Option, + pub dirty_policy: DirtyPolicy, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +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..0ab0b5d677 --- /dev/null +++ b/codex-rs/worktree/src/manager.rs @@ -0,0 +1,636 @@ +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.primary_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 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")?, + )?; + let worktree_arg = worktree_git_root.to_string_lossy(); + let mut args = vec!["worktree", "add"]; + if branch_exists { + args.extend([worktree_arg.as_ref(), branch.as_str()]); + } else if req.base_ref.is_none() && !has_head { + args.extend(["--orphan", "-b", branch.as_str(), worktree_arg.as_ref()]); + } else { + args.extend([ + "-b", + branch.as_str(), + worktree_arg.as_ref(), + req.base_ref.as_deref().unwrap_or("HEAD"), + ]); + } + git::status(&repo.root, &args)?; + + 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 { + 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(), + }) +} + +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); + }; + 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(); + let mut pending = Vec::new(); + for entry in fs::read_dir(root)? { + let entry = entry?; + if entry.file_type()?.is_dir() { + pending.push((entry.path(), 1)); + } + } + + while let Some((path, depth)) = pending.pop() { + if is_git_root(&path) { + roots.push(path); + continue; + } + + if depth == 3 { + continue; + } + + for child in fs::read_dir(path)? { + let child = child?; + if child.file_type()?.is_dir() { + pending.push((child.path(), depth + 1)); + } + } + } + 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 + { + let delete_flag = if req.force { "-D" } else { "-d" }; + git::status(&primary_root, &["branch", delete_flag, &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, + primary_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 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 = 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, + }) + } +} + +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..1497e7ac94 --- /dev/null +++ b/codex-rs/worktree/tests/git_backend.rs @@ -0,0 +1,516 @@ +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 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()?; + 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 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 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 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()?; + 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()?; + 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 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 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<()> { + 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()) +} + +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()) +}