diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index faad516de1..e4a8f861e4 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -352,6 +352,9 @@ struct WorktreeCli { #[derive(Debug, clap::Subcommand)] enum WorktreeSubcommand { + /// Create or reuse a Codex-managed worktree for the current repository. + Create(WorktreeCreateCommand), + /// List Codex-managed worktrees for the current repository. List(WorktreeListCommand), @@ -376,6 +379,20 @@ struct WorktreeListCommand { json: bool, } +#[derive(Debug, Args)] +struct WorktreeCreateCommand { + /// Branch name for the managed worktree. + branch: String, + + /// Base ref for a newly created managed worktree. + #[arg(long = "base", value_name = "REF")] + base_ref: Option, + + /// How to handle uncommitted source checkout changes when creating the worktree. + #[arg(long = "dirty", value_enum, default_value_t = WorktreeDirtyCliArg::Fail)] + dirty: WorktreeDirtyCliArg, +} + #[derive(Debug, Args)] struct WorktreePathCommand { /// Managed worktree name or slug. @@ -789,6 +806,7 @@ fn dirty_policy_from_cli(arg: WorktreeDirtyCliArg) -> DirtyPolicy { WorktreeDirtyCliArg::Ignore => DirtyPolicy::Ignore, WorktreeDirtyCliArg::CopyTracked => DirtyPolicy::CopyTracked, WorktreeDirtyCliArg::CopyAll => DirtyPolicy::CopyAll, + WorktreeDirtyCliArg::MoveTracked => DirtyPolicy::MoveTracked, WorktreeDirtyCliArg::MoveAll => DirtyPolicy::MoveAll, } } @@ -796,6 +814,19 @@ fn dirty_policy_from_cli(arg: WorktreeDirtyCliArg) -> DirtyPolicy { fn run_worktree_command(cli: WorktreeCli) -> anyhow::Result<()> { let codex_home = find_codex_home()?.to_path_buf(); match cli.subcommand { + WorktreeSubcommand::Create(command) => { + let resolution = codex_worktree::ensure_worktree(WorktreeRequest { + codex_home, + source_cwd: std::env::current_dir()?, + branch: command.branch, + base_ref: command.base_ref, + dirty_policy: dirty_policy_from_cli(command.dirty), + })?; + for warning in &resolution.warnings { + eprintln!("warning: {}", warning.message); + } + println!("{}", resolution.info.workspace_cwd.display()); + } WorktreeSubcommand::List(command) => { let source_cwd = if command.all { None @@ -2433,6 +2464,32 @@ mod tests { assert_eq!(cli.interactive.worktree_dirty, WorktreeDirtyCliArg::MoveAll); } + #[test] + fn worktree_create_subcommand_parses() { + let cli = MultitoolCli::try_parse_from([ + "codex", + "worktree", + "create", + "parser-fix", + "--base", + "origin/main", + "--dirty", + "move-tracked", + ]) + .expect("worktree create should parse"); + + let Some(Subcommand::Worktree(WorktreeCli { + subcommand: WorktreeSubcommand::Create(command), + })) = cli.subcommand + else { + panic!("expected worktree create subcommand"); + }; + + assert_eq!(command.branch, "parser-fix"); + assert_eq!(command.base_ref.as_deref(), Some("origin/main")); + assert_eq!(command.dirty, WorktreeDirtyCliArg::MoveTracked); + } + #[test] fn worktree_subcommand_parses() { let cli = MultitoolCli::try_parse_from(["codex", "worktree", "list", "--all", "--json"]) diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 2672339033..ff3372139f 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -188,13 +188,17 @@ impl App { tui.frame_requester().schedule_frame(); } AppEvent::OpenWorktreePicker => { - self.open_worktree_picker(tui); + self.open_worktree_picker(tui, app_server).await; tui.frame_requester().schedule_frame(); } AppEvent::OpenWorktreeCreatePrompt => { self.open_worktree_create_prompt(); tui.frame_requester().schedule_frame(); } + AppEvent::OpenWorktreeBaseRefPrompt { branch } => { + self.open_worktree_base_ref_prompt(branch); + tui.frame_requester().schedule_frame(); + } AppEvent::WorktreesLoaded { cwd, result } => { self.on_worktrees_loaded(cwd, result); tui.frame_requester().schedule_frame(); @@ -204,7 +208,8 @@ impl App { base_ref, dirty_policy, } => { - self.create_worktree_and_switch(tui, branch, base_ref, dirty_policy); + self.create_worktree_and_switch(tui, app_server, branch, base_ref, dirty_policy) + .await; tui.frame_requester().schedule_frame(); } AppEvent::WorktreeCreated { cwd, result } => { @@ -238,7 +243,7 @@ impl App { tui.frame_requester().schedule_frame(); } AppEvent::ShowWorktreePath { target } => { - self.show_worktree_path(target); + self.show_worktree_path(app_server, target).await; tui.frame_requester().schedule_frame(); } AppEvent::RemoveWorktree { @@ -247,7 +252,8 @@ impl App { delete_branch, confirmed, } => { - self.remove_worktree(target, force, delete_branch, confirmed); + self.remove_worktree(app_server, target, force, delete_branch, confirmed) + .await; tui.frame_requester().schedule_frame(); } AppEvent::BeginInitialHistoryReplayBuffer => { diff --git a/codex-rs/tui/src/app/worktree.rs b/codex-rs/tui/src/app/worktree.rs index e5d638179c..d1b1d8c560 100644 --- a/codex-rs/tui/src/app/worktree.rs +++ b/codex-rs/tui/src/app/worktree.rs @@ -1,5 +1,6 @@ //! App-layer handlers for the worktree TUI flow. +use anyhow::Context; use codex_protocol::ThreadId; use codex_worktree::DirtyPolicy; use codex_worktree::WorktreeInfo; @@ -39,29 +40,28 @@ impl WorktreeSessionTransition { } impl App { - pub(super) fn open_worktree_picker(&mut self, tui: &mut tui::Tui) { - if self.remote_app_server_url.is_some() { - self.chat_widget.add_error_message( - "/worktree is not supported for remote sessions yet.".to_string(), - ); - return; - } + pub(super) async fn open_worktree_picker( + &mut self, + tui: &mut tui::Tui, + app_server: &AppServerSession, + ) { self.chat_widget .show_selection_view(crate::worktree::loading_params( tui.frame_requester(), self.config.animations, )); - self.fetch_worktrees_for_picker(); + if self.remote_app_server_url.is_some() { + let result = self + .list_current_repo_worktrees_remote(app_server) + .await + .map_err(|err| err.to_string()); + self.on_worktrees_loaded(self.session_workspace_cwd(app_server).to_path_buf(), result); + } else { + self.fetch_worktrees_for_picker(); + } } pub(super) fn open_worktree_create_prompt(&mut self) { - if self.remote_app_server_url.is_some() { - self.chat_widget.add_error_message( - "/worktree is not supported for remote sessions yet.".to_string(), - ); - return; - } - let tx = self.app_event_tx.clone(); let view = CustomPromptView::new( "New worktree".to_string(), @@ -70,9 +70,29 @@ impl App { /*context_label*/ Some("Creates a sibling worktree and starts this chat there.".to_string()), Box::new(move |branch: String| { - tx.send(AppEvent::CreateWorktreeAndSwitch { + tx.send(AppEvent::OpenWorktreeBaseRefPrompt { branch: branch.trim().to_string(), - base_ref: None, + }); + }), + ); + self.chat_widget.show_bottom_pane_view(Box::new(view)); + } + + pub(super) fn open_worktree_base_ref_prompt(&mut self, branch: String) { + let tx = self.app_event_tx.clone(); + let view = CustomPromptView::new_allow_empty( + "Base ref".to_string(), + "Optional base ref; leave blank for default".to_string(), + /*initial_text*/ String::new(), + /*context_label*/ + Some(format!( + "Create {branch} from this ref, or leave blank for the default." + )), + Box::new(move |base_ref: String| { + let base_ref = base_ref.trim(); + tx.send(AppEvent::CreateWorktreeAndSwitch { + branch: branch.clone(), + base_ref: (!base_ref.is_empty()).then(|| base_ref.to_string()), dirty_policy: None, }); }), @@ -85,33 +105,28 @@ impl App { cwd: PathBuf, result: Result, String>, ) { - if cwd.as_path() != self.config.cwd.as_path() { + if self.remote_app_server_url.is_none() && cwd.as_path() != self.config.cwd.as_path() { return; } let params = match result { Ok(entries) if entries.is_empty() => crate::worktree::empty_params(), - Ok(entries) => crate::worktree::picker_params(entries, self.config.cwd.as_path()), + Ok(entries) => crate::worktree::picker_params(entries, cwd.as_path()), Err(err) => crate::worktree::error_params(err), }; self.replace_worktree_view(params); } - pub(super) fn create_worktree_and_switch( + pub(super) async fn create_worktree_and_switch( &mut self, tui: &mut tui::Tui, + app_server: &AppServerSession, branch: String, base_ref: Option, dirty_policy: Option, ) { - if self.remote_app_server_url.is_some() { - self.chat_widget.add_error_message( - "/worktree is not supported for remote sessions yet.".to_string(), - ); - return; - } let dirty_policy = match dirty_policy { Some(policy) => policy, - None => match codex_worktree::dirty_state(self.config.cwd.as_path()) { + None => match self.source_worktree_dirty_state(app_server).await { Ok(state) if state.is_dirty() => { let params = crate::worktree::dirty_policy_prompt_params(branch, base_ref); self.chat_widget.show_selection_view(params); @@ -127,13 +142,16 @@ impl App { }; self.show_worktree_creating_view(tui, branch.clone()); - self.spawn_worktree_create_request(WorktreeRequest { - codex_home: self.config.codex_home.to_path_buf(), - source_cwd: self.config.cwd.to_path_buf(), - branch, - base_ref, - dirty_policy, - }); + self.spawn_worktree_create_request( + app_server, + WorktreeRequest { + codex_home: self.config.codex_home.to_path_buf(), + source_cwd: self.session_workspace_cwd(app_server).to_path_buf(), + branch, + base_ref, + dirty_policy, + }, + ); } pub(super) async fn on_worktree_created( @@ -143,7 +161,7 @@ impl App { cwd: PathBuf, result: Result, ) { - if cwd.as_path() != self.config.cwd.as_path() { + if cwd.as_path() != self.session_workspace_cwd(app_server) { return; } let resolution = match result { @@ -173,12 +191,6 @@ impl App { } pub(super) fn begin_switch_to_worktree_target(&mut self, tui: &mut tui::Tui, target: String) { - if self.remote_app_server_url.is_some() { - self.chat_widget.add_error_message( - "/worktree is not supported for remote sessions yet.".to_string(), - ); - return; - } self.show_worktree_switching_view(tui, target.clone()); self.defer_switch_to_worktree_target(target); } @@ -194,13 +206,10 @@ impl App { app_server: &mut AppServerSession, target: String, ) { - if self.remote_app_server_url.is_some() { - self.chat_widget.add_error_message( - "/worktree is not supported for remote sessions yet.".to_string(), - ); - return; - } - let entries = match self.list_current_repo_worktrees() { + let entries = match self + .list_current_repo_worktrees_for_session(app_server) + .await + { Ok(entries) => entries, Err(err) => { self.show_worktree_error("Failed to list worktrees.".to_string(), err.to_string()); @@ -218,14 +227,15 @@ impl App { .await; } - pub(super) fn show_worktree_path(&mut self, target: String) { - if self.remote_app_server_url.is_some() { - self.chat_widget.add_error_message( - "/worktree is not supported for remote sessions yet.".to_string(), - ); - return; - } - match self.list_current_repo_worktrees() { + pub(super) async fn show_worktree_path( + &mut self, + app_server: &AppServerSession, + target: String, + ) { + match self + .list_current_repo_worktrees_for_session(app_server) + .await + { Ok(entries) => match crate::worktree::find_worktree(&entries, &target) { Ok(info) => { self.chat_widget.add_info_message( @@ -242,20 +252,18 @@ impl App { } } - pub(super) fn remove_worktree( + pub(super) async fn remove_worktree( &mut self, + app_server: &AppServerSession, target: String, force: bool, delete_branch: bool, confirmed: bool, ) { - if self.remote_app_server_url.is_some() { - self.chat_widget.add_error_message( - "/worktree is not supported for remote sessions yet.".to_string(), - ); - return; - } - let entries = match self.list_current_repo_worktrees() { + let entries = match self + .list_current_repo_worktrees_for_session(app_server) + .await + { Ok(entries) => entries, Err(err) => { self.chat_widget @@ -283,13 +291,33 @@ impl App { return; } - match codex_worktree::remove_worktree(WorktreeRemoveRequest { - codex_home: self.config.codex_home.to_path_buf(), - source_cwd: Some(self.config.cwd.to_path_buf()), - name_or_path: target.clone(), - force, - delete_branch, - }) { + let result = if self.remote_app_server_url.is_some() { + let Some(runner) = self.workspace_command_runner.clone() else { + self.chat_widget.add_error_message( + "Remote worktree removal is unavailable because the workspace command runner is missing." + .to_string(), + ); + return; + }; + crate::remote_worktree::remove_worktree( + &runner, + &app_server.request_handle(), + self.session_workspace_cwd(app_server), + &target, + force, + delete_branch, + ) + .await + } else { + codex_worktree::remove_worktree(WorktreeRemoveRequest { + codex_home: self.config.codex_home.to_path_buf(), + source_cwd: Some(self.session_workspace_cwd(app_server).to_path_buf()), + name_or_path: target.clone(), + force, + delete_branch, + }) + }; + match result { Ok(result) => { let mut message = format!("Removed worktree {}", result.removed_path.display()); if let Some(branch) = result.deleted_branch { @@ -312,6 +340,67 @@ impl App { }) } + async fn list_current_repo_worktrees_for_session( + &self, + app_server: &AppServerSession, + ) -> anyhow::Result> { + if self.remote_app_server_url.is_some() { + self.list_current_repo_worktrees_remote(app_server).await + } else { + self.list_current_repo_worktrees() + } + } + + async fn list_current_repo_worktrees_remote( + &self, + app_server: &AppServerSession, + ) -> anyhow::Result> { + let runner = self + .workspace_command_runner + .clone() + .context("remote worktree operations require a workspace command runner")?; + crate::remote_worktree::list_current_repo_worktrees( + &runner, + &app_server.request_handle(), + self.session_workspace_cwd(app_server), + ) + .await + } + + async fn source_worktree_dirty_state( + &self, + app_server: &AppServerSession, + ) -> anyhow::Result { + if self.remote_app_server_url.is_some() { + let runner = self + .workspace_command_runner + .clone() + .context("remote worktree operations require a workspace command runner")?; + crate::remote_worktree::source_dirty_state( + &runner, + self.session_workspace_cwd(app_server), + ) + .await + } else { + codex_worktree::dirty_state(self.config.cwd.as_path()) + } + } + + fn session_workspace_cwd<'a>(&'a self, app_server: &'a AppServerSession) -> &'a Path { + if self.remote_app_server_url.is_some() { + app_server + .remote_cwd_override() + .or_else(|| { + self.primary_session_configured + .as_ref() + .map(|session| session.cwd.as_path()) + }) + .unwrap_or(self.config.cwd.as_path()) + } else { + self.config.cwd.as_path() + } + } + fn fetch_worktrees_for_picker(&mut self) { let query = WorktreeListQuery { codex_home: self.config.codex_home.to_path_buf(), @@ -326,13 +415,38 @@ impl App { }); } - fn spawn_worktree_create_request(&self, request: WorktreeRequest) { + fn spawn_worktree_create_request( + &self, + app_server: &AppServerSession, + request: WorktreeRequest, + ) { let cwd = request.source_cwd.clone(); let app_event_tx = self.app_event_tx.clone(); - tokio::task::spawn_blocking(move || { - let result = codex_worktree::ensure_worktree(request).map_err(|err| err.to_string()); - app_event_tx.send(AppEvent::WorktreeCreated { cwd, result }); - }); + if self.remote_app_server_url.is_some() { + let Some(runner) = self.workspace_command_runner.clone() else { + app_event_tx.send(AppEvent::WorktreeCreated { + cwd, + result: Err( + "remote worktree operations require a workspace command runner".to_string(), + ), + }); + return; + }; + let request_handle = app_server.request_handle(); + tokio::spawn(async move { + let result = + crate::remote_worktree::ensure_worktree(&runner, &request_handle, request) + .await + .map_err(|err| err.to_string()); + app_event_tx.send(AppEvent::WorktreeCreated { cwd, result }); + }); + } else { + tokio::task::spawn_blocking(move || { + let result = + codex_worktree::ensure_worktree(request).map_err(|err| err.to_string()); + app_event_tx.send(AppEvent::WorktreeCreated { cwd, result }); + }); + } } async fn switch_to_worktree_info( @@ -342,17 +456,21 @@ impl App { info: WorktreeInfo, warnings: Vec, ) { - let mut config = match self - .rebuild_config_for_cwd(info.workspace_cwd.clone()) - .await - { - Ok(config) => config, - Err(err) => { - self.show_worktree_error( - "Failed to rebuild configuration for worktree.".to_string(), - err.to_string(), - ); - return; + let mut config = if app_server.is_remote() { + self.config.clone() + } else { + match self + .rebuild_config_for_cwd(info.workspace_cwd.clone()) + .await + { + Ok(config) => config, + Err(err) => { + self.show_worktree_error( + "Failed to rebuild configuration for worktree.".to_string(), + err.to_string(), + ); + return; + } } }; self.apply_runtime_policy_overrides(&mut config); @@ -387,6 +505,9 @@ impl App { err.to_string(), ); } else { + if app_server.is_remote() { + app_server.set_remote_cwd_override(Some(info.workspace_cwd.clone())); + } let transition = if forked { WorktreeSessionTransition::Forked } else { @@ -419,7 +540,11 @@ impl App { warnings: Vec, ) { let request_handle = app_server.request_handle(); - let remote_cwd_override = app_server.remote_cwd_override().map(Path::to_path_buf); + let remote_cwd_override = if app_server.is_remote() { + Some(info.workspace_cwd.clone()) + } else { + app_server.remote_cwd_override().map(Path::to_path_buf) + }; let app_event_tx = self.app_event_tx.clone(); tokio::spawn(async move { let forked = matches!(mode, WorktreeSwitchMode::Fork(_)); diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 72f32f1d81..8908029204 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -200,6 +200,11 @@ pub(crate) enum AppEvent { /// Open the prompt for creating a managed worktree. OpenWorktreeCreatePrompt, + /// Open the optional base-ref prompt for creating a managed worktree. + OpenWorktreeBaseRefPrompt { + branch: String, + }, + /// Result of loading worktrees for the managed worktree picker. WorktreesLoaded { cwd: PathBuf, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 543bd49884..08752630fc 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -191,6 +191,10 @@ impl AppServerSession { self.remote_cwd_override.as_deref() } + pub(crate) fn set_remote_cwd_override(&mut self, remote_cwd_override: Option) { + self.remote_cwd_override = remote_cwd_override; + } + pub(crate) fn is_remote(&self) -> bool { matches!(self.client, AppServerClient::Remote(_)) } diff --git a/codex-rs/tui/src/bottom_pane/custom_prompt_view.rs b/codex-rs/tui/src/bottom_pane/custom_prompt_view.rs index bce9528fde..a706463fc1 100644 --- a/codex-rs/tui/src/bottom_pane/custom_prompt_view.rs +++ b/codex-rs/tui/src/bottom_pane/custom_prompt_view.rs @@ -31,6 +31,7 @@ pub(crate) struct CustomPromptView { placeholder: String, context_label: Option, on_submit: PromptSubmitted, + allow_empty: bool, // UI state textarea: TextArea, @@ -57,11 +58,24 @@ impl CustomPromptView { placeholder, context_label, on_submit, + allow_empty: false, textarea, textarea_state: RefCell::new(TextAreaState::default()), completion: None, } } + + pub(crate) fn new_allow_empty( + title: String, + placeholder: String, + initial_text: String, + context_label: Option, + on_submit: PromptSubmitted, + ) -> Self { + let mut view = Self::new(title, placeholder, initial_text, context_label, on_submit); + view.allow_empty = true; + view + } } impl BottomPaneView for CustomPromptView { @@ -78,7 +92,7 @@ impl BottomPaneView for CustomPromptView { .. } => { let text = self.textarea.text().trim().to_string(); - if !text.is_empty() { + if self.allow_empty || !text.is_empty() { (self.on_submit)(text); self.completion = Some(ViewCompletion::Accepted); } diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 48de5bbfc4..55b0c4ef1a 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -179,6 +179,7 @@ mod tui; mod ui_consts; pub(crate) mod update_action; pub use update_action::UpdateAction; +mod remote_worktree; mod worktree; mod worktree_labels; #[cfg(not(debug_assertions))] diff --git a/codex-rs/tui/src/remote_worktree.rs b/codex-rs/tui/src/remote_worktree.rs new file mode 100644 index 0000000000..fbf3331e40 --- /dev/null +++ b/codex-rs/tui/src/remote_worktree.rs @@ -0,0 +1,868 @@ +use std::path::Path; +use std::path::PathBuf; + +use anyhow::Context; +use anyhow::Result; +use base64::Engine; +use base64::engine::general_purpose::STANDARD; +use codex_app_server_client::AppServerRequestHandle; +use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::FsCopyParams; +use codex_app_server_protocol::FsCopyResponse; +use codex_app_server_protocol::FsCreateDirectoryParams; +use codex_app_server_protocol::FsCreateDirectoryResponse; +use codex_app_server_protocol::FsGetMetadataParams; +use codex_app_server_protocol::FsGetMetadataResponse; +use codex_app_server_protocol::FsReadFileParams; +use codex_app_server_protocol::FsReadFileResponse; +use codex_app_server_protocol::FsRemoveParams; +use codex_app_server_protocol::FsRemoveResponse; +use codex_app_server_protocol::FsWriteFileParams; +use codex_app_server_protocol::FsWriteFileResponse; +use codex_app_server_protocol::RequestId; +use codex_utils_absolute_path::AbsolutePathBuf; +use codex_worktree::DirtyPolicy; +use codex_worktree::DirtyState; +use codex_worktree::WorktreeInfo; +use codex_worktree::WorktreeLocation; +use codex_worktree::WorktreeMetadata; +use codex_worktree::WorktreeRemoveResult; +use codex_worktree::WorktreeRequest; +use codex_worktree::WorktreeResolution; +use codex_worktree::WorktreeSource; +use codex_worktree::WorktreeThreadMetadata; +use codex_worktree::WorktreeWarning; +use uuid::Uuid; + +use crate::workspace_command::WorkspaceCommand; +use crate::workspace_command::WorkspaceCommandRunner; + +pub(crate) async fn list_current_repo_worktrees( + runner: &WorkspaceCommandRunner, + request_handle: &AppServerRequestHandle, + source_cwd: &Path, +) -> Result> { + let repo = RemoteSourceRepo::resolve(runner, source_cwd).await?; + let worktrees = parse_worktree_list( + &git_stdout(runner, &repo.root, &["worktree", "list", "--porcelain"]).await?, + ); + let mut infos = Vec::new(); + for entry in worktrees { + infos.push( + info_from_existing_worktree(runner, request_handle, &entry.path, entry.branch, &repo) + .await?, + ); + } + Ok(infos) +} + +pub(crate) async fn source_dirty_state( + runner: &WorkspaceCommandRunner, + source_cwd: &Path, +) -> Result { + let repo = RemoteSourceRepo::resolve(runner, source_cwd).await?; + dirty_state(runner, &repo.root).await +} + +pub(crate) async fn ensure_worktree( + runner: &WorkspaceCommandRunner, + request_handle: &AppServerRequestHandle, + req: WorktreeRequest, +) -> Result { + let repo = RemoteSourceRepo::resolve(runner, &req.source_cwd).await?; + let branch = req.branch.clone(); + git_status( + runner, + &repo.root, + &["check-ref-format", "--branch", &branch], + ) + .await?; + let worktree_git_root = codex_worktree::sibling_worktree_git_root(&repo.primary_root, &branch)?; + let workspace_cwd = worktree_git_root.join(&repo.relative_cwd); + + if path_exists(request_handle, &worktree_git_root).await? { + let metadata = read_metadata(runner, request_handle, &worktree_git_root) + .await? + .context("managed worktree path already exists but is not owned by Codex")?; + if metadata.branch.as_deref() != Some(branch.as_str()) && metadata.name != branch { + anyhow::bail!( + "managed worktree path {} is already used by {}; choose a different branch name", + worktree_git_root.display(), + metadata.branch.as_deref().unwrap_or(metadata.name.as_str()) + ); + } + let info = info_from_existing_worktree( + runner, + request_handle, + &worktree_git_root, + Some(branch), + &repo, + ) + .await?; + return Ok(WorktreeResolution { + reused: true, + info, + warnings: Vec::new(), + }); + } + + let worktrees = parse_worktree_list( + &git_stdout(runner, &repo.root, &["worktree", "list", "--porcelain"]).await?, + ); + if let Some(existing) = worktrees + .iter() + .find(|entry| entry.branch.as_deref() == Some(branch.as_str())) + && existing.path != worktree_git_root + { + anyhow::bail!( + "branch {branch} is already checked out at {}; remove that worktree first", + existing.path.display() + ); + } + + let warnings = + validate_dirty_policy_before_create(runner, &repo.root, req.dirty_policy).await?; + create_directory( + request_handle, + worktree_git_root + .parent() + .context("managed worktree path has no parent")?, + ) + .await?; + let branch_exists = git_status_result( + runner, + &repo.root, + &[ + "show-ref", + "--verify", + "--quiet", + &format!("refs/heads/{branch}"), + ], + ) + .await? + .success(); + let has_head = git_status_result(runner, &repo.root, &["rev-parse", "--verify", "HEAD"]) + .await? + .success(); + if branch_exists { + git_status( + runner, + &repo.root, + &[ + "worktree", + "add", + &worktree_git_root.to_string_lossy(), + &branch, + ], + ) + .await?; + } else if req.base_ref.is_none() && !has_head { + git_status( + runner, + &repo.root, + &[ + "worktree", + "add", + "--orphan", + "-b", + &branch, + &worktree_git_root.to_string_lossy(), + ], + ) + .await?; + } else { + let base_ref = req.base_ref.as_deref().unwrap_or("HEAD"); + git_status( + runner, + &repo.root, + &[ + "worktree", + "add", + "-b", + &branch, + &worktree_git_root.to_string_lossy(), + base_ref, + ], + ) + .await?; + } + + apply_dirty_policy_after_create( + runner, + request_handle, + &repo.root, + &worktree_git_root, + req.dirty_policy, + ) + .await?; + let dirty = dirty_state(runner, &worktree_git_root).await?; + let head = git_stdout_result(runner, &worktree_git_root, &["rev-parse", "HEAD"]) + .await? + .success() + .then_some(async { git_stdout(runner, &worktree_git_root, &["rev-parse", "HEAD"]).await }); + let head = match head { + Some(future) => Some(future.await?), + None => None, + }; + let info_metadata_path = + metadata_path(runner, &worktree_git_root, "codex-worktree.json").await?; + let mut info = WorktreeInfo { + id: repo.id.clone(), + name: branch.clone(), + slug: codex_worktree::slugify_name(&branch)?, + source: WorktreeSource::Cli, + location: WorktreeLocation::Sibling, + repo_name: repo.repo_name.clone(), + repo_root: repo.root.clone(), + common_git_dir: repo.common_git_dir.clone(), + worktree_git_root: worktree_git_root.clone(), + workspace_cwd, + original_relative_cwd: repo.relative_cwd.clone(), + branch: Some(branch), + head, + owner_thread_id: None, + metadata_path: info_metadata_path, + dirty, + }; + write_json( + request_handle, + &metadata_path(runner, &worktree_git_root, "codex-thread.json").await?, + &WorktreeThreadMetadata { + version: 1, + owner_thread_id: None, + }, + ) + .await?; + let metadata = WorktreeMetadata::from_info(&info, repo.root); + write_json(request_handle, &info.metadata_path, &metadata).await?; + info.owner_thread_id = metadata.owner_thread_id; + Ok(WorktreeResolution { + reused: false, + info, + warnings: warnings + .into_iter() + .map(|message| WorktreeWarning { message }) + .collect(), + }) +} + +pub(crate) async fn remove_worktree( + runner: &WorkspaceCommandRunner, + request_handle: &AppServerRequestHandle, + source_cwd: &Path, + target: &str, + force: bool, + delete_branch: bool, +) -> Result { + let entries = list_current_repo_worktrees(runner, request_handle, source_cwd).await?; + let info = entries + .into_iter() + .find(|entry| { + entry.branch.as_deref() == Some(target) || entry.name == target || entry.slug == target + }) + .context("no managed worktree matched target")?; + let metadata = read_metadata(runner, request_handle, &info.worktree_git_root) + .await? + .context("refusing to remove a worktree not managed by Codex")?; + if metadata.source != WorktreeSource::Cli { + anyhow::bail!("refusing to remove a worktree not managed by Codex CLI"); + } + if info.dirty.is_dirty() && !force { + anyhow::bail!( + "refusing to remove dirty worktree {}; use --force to override", + info.worktree_git_root.display() + ); + } + let repo = RemoteSourceRepo::resolve(runner, source_cwd).await?; + let mut args = vec!["worktree", "remove"]; + if force { + args.push("--force"); + } + let target_path = info.worktree_git_root.to_string_lossy().to_string(); + args.push(&target_path); + git_status(runner, &repo.primary_root, &args).await?; + let mut deleted_branch = None; + if delete_branch && let Some(branch) = info.branch { + let delete_flag = if force { "-D" } else { "-d" }; + git_status( + runner, + &repo.primary_root, + &["branch", delete_flag, &branch], + ) + .await?; + deleted_branch = Some(branch); + } + Ok(WorktreeRemoveResult { + removed_path: info.worktree_git_root, + deleted_branch, + }) +} + +#[derive(Clone)] +struct RemoteSourceRepo { + root: PathBuf, + primary_root: PathBuf, + relative_cwd: PathBuf, + common_git_dir: PathBuf, + repo_name: String, + id: String, +} + +impl RemoteSourceRepo { + async fn resolve(runner: &WorkspaceCommandRunner, source_cwd: &Path) -> Result { + let root = + PathBuf::from(git_stdout(runner, source_cwd, &["rev-parse", "--show-toplevel"]).await?); + let common_git_dir_raw = + git_stdout(runner, source_cwd, &["rev-parse", "--git-common-dir"]).await?; + let common_git_dir = absolutize(source_cwd, Path::new(&common_git_dir_raw)); + let primary_root = parse_worktree_list( + &git_stdout(runner, &root, &["worktree", "list", "--porcelain"]).await?, + ) + .into_iter() + .next() + .map(|entry| entry.path) + .context("git did not report a primary worktree")?; + let origin = git_stdout_result(runner, &root, &["remote", "get-url", "origin"]).await?; + let origin = origin.success().then_some(origin.stdout.trim().to_string()); + let id = codex_worktree::repo_fingerprint(&common_git_dir, origin.as_deref()); + let repo_name = primary_root + .file_name() + .context("repository root has no directory name")? + .to_string_lossy() + .to_string(); + let relative_cwd = source_cwd + .strip_prefix(&root) + .unwrap_or_else(|_| Path::new("")) + .to_path_buf(); + Ok(Self { + root, + primary_root, + relative_cwd, + common_git_dir, + repo_name, + id, + }) + } +} + +async fn info_from_existing_worktree( + runner: &WorkspaceCommandRunner, + request_handle: &AppServerRequestHandle, + worktree_git_root: &Path, + fallback_branch: Option, + repo: &RemoteSourceRepo, +) -> Result { + let metadata = read_metadata(runner, request_handle, worktree_git_root).await?; + let branch = git_stdout_result( + runner, + worktree_git_root, + &["symbolic-ref", "--quiet", "--short", "HEAD"], + ) + .await? + .success() + .then_some(async { + git_stdout( + runner, + worktree_git_root, + &["symbolic-ref", "--quiet", "--short", "HEAD"], + ) + .await + }); + let branch = match branch { + Some(future) => Some(future.await?), + None => fallback_branch, + }; + let head = git_stdout_result(runner, worktree_git_root, &["rev-parse", "HEAD"]) + .await? + .success() + .then_some(async { git_stdout(runner, worktree_git_root, &["rev-parse", "HEAD"]).await }); + let head = match head { + Some(future) => Some(future.await?), + None => None, + }; + let dirty = dirty_state(runner, worktree_git_root) + .await + .unwrap_or_default(); + let name = metadata + .as_ref() + .map(|metadata| metadata.name.clone()) + .or_else(|| branch.clone()) + .unwrap_or_else(|| repo.repo_name.clone()); + let slug = metadata + .as_ref() + .map(|metadata| metadata.slug.clone()) + .unwrap_or_else(|| name.replace(['/', '\\'], "-")); + Ok(WorktreeInfo { + id: metadata + .as_ref() + .map(|metadata| metadata.repo_id.clone()) + .unwrap_or_else(|| repo.id.clone()), + name, + slug, + source: metadata + .as_ref() + .map(|metadata| metadata.source) + .unwrap_or(WorktreeSource::Git), + location: metadata + .as_ref() + .map(|metadata| metadata.location) + .unwrap_or(WorktreeLocation::External), + repo_name: repo.repo_name.clone(), + repo_root: repo.root.clone(), + common_git_dir: repo.common_git_dir.clone(), + worktree_git_root: worktree_git_root.to_path_buf(), + workspace_cwd: metadata + .as_ref() + .map(|metadata| metadata.workspace_cwd.clone()) + .unwrap_or_else(|| worktree_git_root.to_path_buf()), + original_relative_cwd: metadata + .as_ref() + .map(|metadata| metadata.original_relative_cwd.clone()) + .unwrap_or_default(), + branch, + head, + owner_thread_id: metadata.and_then(|metadata| metadata.owner_thread_id), + metadata_path: metadata_path(runner, worktree_git_root, "codex-worktree.json").await?, + dirty, + }) +} + +async fn dirty_state(runner: &WorkspaceCommandRunner, root: &Path) -> Result { + Ok(DirtyState { + has_staged_changes: !git_stdout(runner, root, &["diff", "--cached", "--name-only", "-z"]) + .await? + .is_empty(), + has_unstaged_changes: !git_stdout(runner, root, &["diff", "--name-only", "-z"]) + .await? + .is_empty(), + has_untracked_files: !git_stdout( + runner, + root, + &["ls-files", "--others", "--exclude-standard", "-z"], + ) + .await? + .is_empty(), + }) +} + +async fn validate_dirty_policy_before_create( + runner: &WorkspaceCommandRunner, + root: &Path, + policy: DirtyPolicy, +) -> Result> { + let state = dirty_state(runner, root).await?; + if !state.is_dirty() { + return Ok(Vec::new()); + } + match policy { + DirtyPolicy::Fail => anyhow::bail!( + "source checkout has uncommitted changes; use --worktree-dirty ignore, copy-tracked, copy-all, move-tracked, or move-all" + ), + DirtyPolicy::Ignore => Ok(vec![ + "source checkout has uncommitted changes; the new worktree was created without them" + .to_string(), + ]), + DirtyPolicy::CopyTracked | DirtyPolicy::MoveTracked if state.has_untracked_files => Ok(vec![ + "untracked files were left in the source checkout; use --worktree-dirty copy-all or move-all to carry them" + .to_string(), + ]), + DirtyPolicy::CopyTracked + | DirtyPolicy::CopyAll + | DirtyPolicy::MoveTracked + | DirtyPolicy::MoveAll => Ok(Vec::new()), + } +} + +async fn apply_dirty_policy_after_create( + runner: &WorkspaceCommandRunner, + request_handle: &AppServerRequestHandle, + source_root: &Path, + worktree_root: &Path, + policy: DirtyPolicy, +) -> Result<()> { + let state = dirty_state(runner, source_root).await?; + if !state.is_dirty() { + return Ok(()); + } + let plan = RemoteTransferPlan::capture(runner, source_root).await?; + match policy { + DirtyPolicy::Fail | DirtyPolicy::Ignore => Ok(()), + DirtyPolicy::CopyTracked => { + plan.apply_tracked_diff(runner, request_handle, worktree_root) + .await + } + DirtyPolicy::CopyAll => { + plan.apply_tracked_diff(runner, request_handle, worktree_root) + .await?; + plan.copy_untracked(request_handle, source_root, worktree_root) + .await + } + DirtyPolicy::MoveTracked => { + plan.apply_tracked_diff(runner, request_handle, worktree_root) + .await?; + plan.clean_source_after_move( + runner, + request_handle, + source_root, + /*move_untracked*/ false, + ) + .await + } + DirtyPolicy::MoveAll => { + plan.apply_tracked_diff(runner, request_handle, worktree_root) + .await?; + plan.copy_untracked(request_handle, source_root, worktree_root) + .await?; + plan.clean_source_after_move( + runner, + request_handle, + source_root, + /*move_untracked*/ true, + ) + .await + } + } +} + +struct RemoteTransferPlan { + staged_diff: String, + unstaged_diff: String, + tracked_paths: Vec, + untracked_paths: Vec, +} + +impl RemoteTransferPlan { + async fn capture(runner: &WorkspaceCommandRunner, source_root: &Path) -> Result { + Ok(Self { + staged_diff: git_stdout(runner, source_root, &["diff", "--cached", "--binary"]).await?, + unstaged_diff: git_stdout(runner, source_root, &["diff", "--binary"]).await?, + tracked_paths: tracked_paths(runner, source_root).await?, + untracked_paths: untracked_paths(runner, source_root).await?, + }) + } + + async fn apply_tracked_diff( + &self, + runner: &WorkspaceCommandRunner, + request_handle: &AppServerRequestHandle, + worktree_root: &Path, + ) -> Result<()> { + if !self.staged_diff.is_empty() { + apply_patch_file( + runner, + request_handle, + worktree_root, + "staged", + &self.staged_diff, + &["apply", "--index", "--binary"], + ) + .await?; + } + if !self.unstaged_diff.is_empty() { + apply_patch_file( + runner, + request_handle, + worktree_root, + "unstaged", + &self.unstaged_diff, + &["apply", "--binary"], + ) + .await?; + } + Ok(()) + } + + async fn copy_untracked( + &self, + request_handle: &AppServerRequestHandle, + source_root: &Path, + worktree_root: &Path, + ) -> Result<()> { + for relative_path in &self.untracked_paths { + if let Some(parent) = worktree_root.join(relative_path).parent() { + create_directory(request_handle, parent).await?; + } + fs_copy( + request_handle, + &source_root.join(relative_path), + &worktree_root.join(relative_path), + ) + .await?; + } + Ok(()) + } + + async fn clean_source_after_move( + &self, + runner: &WorkspaceCommandRunner, + request_handle: &AppServerRequestHandle, + source_root: &Path, + move_untracked: bool, + ) -> Result<()> { + if git_status_result(runner, source_root, &["rev-parse", "--verify", "HEAD"]) + .await? + .success() + { + git_status(runner, source_root, &["reset", "--hard", "HEAD"]).await?; + } else { + git_status(runner, source_root, &["read-tree", "--empty"]).await?; + for relative_path in &self.tracked_paths { + fs_remove(request_handle, &source_root.join(relative_path)).await?; + } + } + if move_untracked { + for relative_path in &self.untracked_paths { + fs_remove(request_handle, &source_root.join(relative_path)).await?; + } + } + Ok(()) + } +} + +async fn apply_patch_file( + runner: &WorkspaceCommandRunner, + request_handle: &AppServerRequestHandle, + cwd: &Path, + label: &str, + contents: &str, + git_args: &[&str], +) -> Result<()> { + let patch_path = metadata_path( + runner, + cwd, + &format!("codex-worktree-{label}-{}.patch", Uuid::new_v4()), + ) + .await?; + fs_write(request_handle, &patch_path, contents.as_bytes()).await?; + let mut args = git_args.to_vec(); + let patch_arg = patch_path.to_string_lossy().to_string(); + args.push(&patch_arg); + let result = git_status(runner, cwd, &args).await; + let _ = fs_remove(request_handle, &patch_path).await; + result +} + +async fn tracked_paths(runner: &WorkspaceCommandRunner, root: &Path) -> Result> { + let mut paths = paths_from_nul_separated( + &git_stdout(runner, root, &["diff", "--cached", "--name-only", "-z"]).await?, + ); + paths.extend(paths_from_nul_separated( + &git_stdout(runner, root, &["diff", "--name-only", "-z"]).await?, + )); + paths.sort(); + paths.dedup(); + Ok(paths) +} + +async fn untracked_paths(runner: &WorkspaceCommandRunner, root: &Path) -> Result> { + Ok(paths_from_nul_separated( + &git_stdout( + runner, + root, + &["ls-files", "--others", "--exclude-standard", "-z"], + ) + .await?, + )) +} + +fn paths_from_nul_separated(output: &str) -> Vec { + output + .split('\0') + .filter(|path| !path.is_empty()) + .map(PathBuf::from) + .collect() +} + +async fn read_metadata( + runner: &WorkspaceCommandRunner, + request_handle: &AppServerRequestHandle, + worktree_root: &Path, +) -> Result> { + let path = metadata_path(runner, worktree_root, "codex-worktree.json").await?; + if !path_exists(request_handle, &path).await? { + return Ok(None); + } + let contents = fs_read(request_handle, &path).await?; + Ok(Some(serde_json::from_slice(&contents)?)) +} + +async fn write_json( + request_handle: &AppServerRequestHandle, + path: &Path, + value: &T, +) -> Result<()> { + fs_write(request_handle, path, &serde_json::to_vec_pretty(value)?).await +} + +async fn metadata_path( + runner: &WorkspaceCommandRunner, + root: &Path, + name: &str, +) -> Result { + Ok(absolutize( + root, + Path::new(&git_stdout(runner, root, &["rev-parse", "--git-path", name]).await?), + )) +} + +fn absolutize(cwd: &Path, path: &Path) -> PathBuf { + if path.is_absolute() { + path.to_path_buf() + } else { + cwd.join(path) + } +} + +#[derive(Debug)] +struct GitWorktreeEntry { + path: PathBuf, + branch: Option, +} + +fn parse_worktree_list(output: &str) -> Vec { + let mut entries = Vec::new(); + let mut path = None; + let mut branch = None; + for line in output.lines().chain(std::iter::once("")) { + if line.is_empty() { + if let Some(path) = path.take() { + entries.push(GitWorktreeEntry { + path, + branch: branch.take(), + }); + } + continue; + } + if let Some(raw_path) = line.strip_prefix("worktree ") { + path = Some(PathBuf::from(raw_path)); + } else if let Some(raw_branch) = line.strip_prefix("branch ") { + branch = Some(raw_branch.trim_start_matches("refs/heads/").to_string()); + } + } + entries +} + +async fn git_stdout(runner: &WorkspaceCommandRunner, cwd: &Path, args: &[&str]) -> Result { + let output = git_stdout_result(runner, cwd, args).await?; + if !output.success() { + anyhow::bail!("git {} failed: {}", args.join(" "), output.stderr.trim()); + } + Ok(output.stdout.trim_end().to_string()) +} + +async fn git_stdout_result( + runner: &WorkspaceCommandRunner, + cwd: &Path, + args: &[&str], +) -> Result { + let argv = std::iter::once("git") + .chain(args.iter().copied()) + .collect::>(); + Ok(runner.run(WorkspaceCommand::new(argv).cwd(cwd)).await?) +} + +async fn git_status(runner: &WorkspaceCommandRunner, cwd: &Path, args: &[&str]) -> Result<()> { + let output = git_status_result(runner, cwd, args).await?; + if !output.success() { + anyhow::bail!("git {} failed: {}", args.join(" "), output.stderr.trim()); + } + Ok(()) +} + +async fn git_status_result( + runner: &WorkspaceCommandRunner, + cwd: &Path, + args: &[&str], +) -> Result { + git_stdout_result(runner, cwd, args).await +} + +fn absolute_path(path: &Path) -> Result { + AbsolutePathBuf::from_absolute_path(path).map_err(Into::into) +} + +async fn path_exists(request_handle: &AppServerRequestHandle, path: &Path) -> Result { + let result: Result = request_handle + .request_typed(ClientRequest::FsGetMetadata { + request_id: RequestId::String(format!("worktree-fs-meta-{}", Uuid::new_v4())), + params: FsGetMetadataParams { + path: absolute_path(path)?, + }, + }) + .await; + Ok(result.is_ok()) +} + +async fn create_directory(request_handle: &AppServerRequestHandle, path: &Path) -> Result<()> { + let _: FsCreateDirectoryResponse = request_handle + .request_typed(ClientRequest::FsCreateDirectory { + request_id: RequestId::String(format!("worktree-fs-mkdir-{}", Uuid::new_v4())), + params: FsCreateDirectoryParams { + path: absolute_path(path)?, + recursive: Some(true), + }, + }) + .await?; + Ok(()) +} + +async fn fs_read(request_handle: &AppServerRequestHandle, path: &Path) -> Result> { + let response: FsReadFileResponse = request_handle + .request_typed(ClientRequest::FsReadFile { + request_id: RequestId::String(format!("worktree-fs-read-{}", Uuid::new_v4())), + params: FsReadFileParams { + path: absolute_path(path)?, + }, + }) + .await?; + Ok(STANDARD.decode(response.data_base64)?) +} + +async fn fs_write( + request_handle: &AppServerRequestHandle, + path: &Path, + bytes: &[u8], +) -> Result<()> { + let _: FsWriteFileResponse = request_handle + .request_typed(ClientRequest::FsWriteFile { + request_id: RequestId::String(format!("worktree-fs-write-{}", Uuid::new_v4())), + params: FsWriteFileParams { + path: absolute_path(path)?, + data_base64: STANDARD.encode(bytes), + }, + }) + .await?; + Ok(()) +} + +async fn fs_copy( + request_handle: &AppServerRequestHandle, + source_path: &Path, + destination_path: &Path, +) -> Result<()> { + let _: FsCopyResponse = request_handle + .request_typed(ClientRequest::FsCopy { + request_id: RequestId::String(format!("worktree-fs-copy-{}", Uuid::new_v4())), + params: FsCopyParams { + source_path: absolute_path(source_path)?, + destination_path: absolute_path(destination_path)?, + recursive: false, + }, + }) + .await?; + Ok(()) +} + +async fn fs_remove(request_handle: &AppServerRequestHandle, path: &Path) -> Result<()> { + let _: FsRemoveResponse = request_handle + .request_typed(ClientRequest::FsRemove { + request_id: RequestId::String(format!("worktree-fs-remove-{}", Uuid::new_v4())), + params: FsRemoveParams { + path: absolute_path(path)?, + recursive: Some(false), + force: Some(true), + }, + }) + .await?; + Ok(()) +} diff --git a/codex-rs/tui/src/snapshots/codex_tui__worktree__tests__worktree_dirty_policy_prompt.snap b/codex-rs/tui/src/snapshots/codex_tui__worktree__tests__worktree_dirty_policy_prompt.snap index afa9226de2..e4d7b84ac3 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__worktree__tests__worktree_dirty_policy_prompt.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__worktree__tests__worktree_dirty_policy_prompt.snap @@ -9,8 +9,10 @@ expression: "render_selection(dirty_policy_prompt_params(\"fcoury/demo\".to_stri › 1. Move all Move tracked changes and untracked files; leave the source checkout clean. 2. Copy all Copy tracked changes and untracked files. - 3. Copy tracked Copy staged and unstaged tracked changes. - 4. Ignore Create from the requested base without copying local changes. - 5. Fail Cancel creation and leave the source checkout unchanged. + 3. Move tracked Move staged and unstaged tracked changes; leave untracked + files behind. + 4. Copy tracked Copy staged and unstaged tracked changes. + 5. Ignore Create from the requested base without copying local changes. + 6. Fail Cancel creation and leave the source checkout unchanged. Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/worktree.rs b/codex-rs/tui/src/worktree.rs index e01450f3d3..41a07b21a1 100644 --- a/codex-rs/tui/src/worktree.rs +++ b/codex-rs/tui/src/worktree.rs @@ -236,9 +236,10 @@ fn parse_dirty_policy(value: &str) -> Result { "ignore" => Ok(DirtyPolicy::Ignore), "copy-tracked" => Ok(DirtyPolicy::CopyTracked), "copy-all" => Ok(DirtyPolicy::CopyAll), + "move-tracked" => Ok(DirtyPolicy::MoveTracked), "move-all" => Ok(DirtyPolicy::MoveAll), _ => Err( - "Dirty mode must be one of: fail, ignore, copy-tracked, copy-all, move-all." + "Dirty mode must be one of: fail, ignore, copy-tracked, copy-all, move-tracked, move-all." .to_string(), ), } @@ -492,6 +493,11 @@ pub(crate) fn dirty_policy_prompt_params( "Copy tracked changes and untracked files.", DirtyPolicy::CopyAll, ), + item( + "Move tracked", + "Move staged and unstaged tracked changes; leave untracked files behind.", + DirtyPolicy::MoveTracked, + ), item( "Copy tracked", "Copy staged and unstaged tracked changes.", @@ -643,6 +649,18 @@ mod tests { ); } + #[test] + fn parse_new_with_move_tracked_dirty_policy() { + assert_eq!( + parse_worktree_slash_args("new fcoury/demo --dirty move-tracked"), + Ok(WorktreeSlashAction::Create { + branch: "fcoury/demo".to_string(), + base_ref: /*base_ref*/ None, + dirty_policy: Some(DirtyPolicy::MoveTracked), + }) + ); + } + #[test] fn parse_switch_aliases_move() { assert_eq!( diff --git a/codex-rs/utils/cli/src/worktree_dirty_cli_arg.rs b/codex-rs/utils/cli/src/worktree_dirty_cli_arg.rs index eafd46a5dd..cf90771433 100644 --- a/codex-rs/utils/cli/src/worktree_dirty_cli_arg.rs +++ b/codex-rs/utils/cli/src/worktree_dirty_cli_arg.rs @@ -8,5 +8,6 @@ pub enum WorktreeDirtyCliArg { Ignore, CopyTracked, CopyAll, + MoveTracked, MoveAll, } diff --git a/codex-rs/worktree/src/dirty.rs b/codex-rs/worktree/src/dirty.rs index 8f2e00ef55..8fd3cce886 100644 --- a/codex-rs/worktree/src/dirty.rs +++ b/codex-rs/worktree/src/dirty.rs @@ -16,9 +16,18 @@ pub enum DirtyPolicy { Ignore, CopyTracked, CopyAll, + MoveTracked, MoveAll, } +#[derive(Debug)] +struct TransferPlan { + staged_diff: Vec, + unstaged_diff: Vec, + tracked_paths: Vec, + untracked_paths: Vec, +} + #[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct DirtyState { @@ -59,7 +68,7 @@ pub fn validate_dirty_policy_before_create( "source checkout has uncommitted changes; the new worktree was created without them" .to_string(), ]), - DirtyPolicy::CopyTracked => { + DirtyPolicy::CopyTracked | DirtyPolicy::MoveTracked => { if state.has_untracked_files { Ok(vec![ "untracked files were left in the source checkout; use --worktree-dirty copy-all or move-all to carry them" @@ -85,17 +94,33 @@ pub fn apply_dirty_policy_after_create( match policy { DirtyPolicy::Fail | DirtyPolicy::Ignore => Ok(()), - DirtyPolicy::CopyTracked => apply_tracked_diff(source_root, worktree_root), + DirtyPolicy::CopyTracked => { + let plan = TransferPlan::capture(source_root)?; + plan.apply_tracked_diff(worktree_root) + } DirtyPolicy::CopyAll => { - apply_tracked_diff(source_root, worktree_root)?; - copy_untracked_files(source_root, worktree_root)?; + let plan = TransferPlan::capture(source_root)?; + plan.apply_tracked_diff(worktree_root)?; + copy_untracked_files_at_paths(source_root, worktree_root, &plan.untracked_paths)?; + Ok(()) + } + DirtyPolicy::MoveTracked => { + let plan = TransferPlan::capture(source_root)?; + plan.apply_tracked_diff(worktree_root)?; + plan.clean_source_after_move(source_root, /*move_untracked*/ false) + .with_context(|| { + "worktree already contains transferred changes, but failed to clean the source checkout after move" + })?; Ok(()) } DirtyPolicy::MoveAll => { - let untracked_paths = untracked_paths(source_root)?; - apply_tracked_diff(source_root, worktree_root)?; - copy_untracked_files_at_paths(source_root, worktree_root, &untracked_paths)?; - clean_source_after_move(source_root, &untracked_paths)?; + let plan = TransferPlan::capture(source_root)?; + plan.apply_tracked_diff(worktree_root)?; + copy_untracked_files_at_paths(source_root, worktree_root, &plan.untracked_paths)?; + plan.clean_source_after_move(source_root, /*move_untracked*/ true) + .with_context(|| { + "worktree already contains transferred changes, but failed to clean the source checkout after move" + })?; Ok(()) } } @@ -103,39 +128,71 @@ pub fn apply_dirty_policy_after_create( fn bail_for_dirty_source() -> Result { anyhow::bail!( - "source checkout has uncommitted changes; use --worktree-dirty ignore, copy-tracked, copy-all, or move-all" + "source checkout has uncommitted changes; use --worktree-dirty ignore, copy-tracked, copy-all, move-tracked, or move-all" ); } -fn apply_tracked_diff(source_root: &Path, worktree_root: &Path) -> Result<()> { - let staged = git::bytes(source_root, &["diff", "--cached", "--binary"])?; - let unstaged = git::bytes(source_root, &["diff", "--binary"])?; - - if !staged.is_empty() { - git::status_with_stdin( - worktree_root, - &["apply", "--index", "--binary", "-"], - &staged, - ) - .context("failed to apply staged changes to worktree")?; +impl TransferPlan { + fn capture(source_root: &Path) -> Result { + Ok(Self { + staged_diff: git::bytes(source_root, &["diff", "--cached", "--binary"])?, + unstaged_diff: git::bytes(source_root, &["diff", "--binary"])?, + tracked_paths: tracked_paths(source_root)?, + untracked_paths: untracked_paths(source_root)?, + }) } - if !unstaged.is_empty() { - git::status_with_stdin(worktree_root, &["apply", "--binary", "-"], &unstaged) + + fn apply_tracked_diff(&self, worktree_root: &Path) -> Result<()> { + if !self.staged_diff.is_empty() { + git::status_with_stdin( + worktree_root, + &["apply", "--index", "--binary", "-"], + &self.staged_diff, + ) + .context("failed to apply staged changes to worktree")?; + } + if !self.unstaged_diff.is_empty() { + git::status_with_stdin( + worktree_root, + &["apply", "--binary", "-"], + &self.unstaged_diff, + ) .context("failed to apply unstaged changes to worktree")?; + } + Ok(()) + } + + fn clean_source_after_move(&self, source_root: &Path, move_untracked: bool) -> Result<()> { + if has_head(source_root) { + git::status(source_root, &["reset", "--hard", "HEAD"]) + .context("failed to clean tracked changes from source checkout after move")?; + } else { + git::status(source_root, &["read-tree", "--empty"]) + .context("failed to clear unborn source index after move")?; + for relative_path in &self.tracked_paths { + remove_file_if_present(source_root, relative_path, "tracked")?; + } + } + if move_untracked { + for relative_path in &self.untracked_paths { + remove_file_if_present(source_root, relative_path, "untracked")?; + } + } + Ok(()) } - Ok(()) } -fn copy_untracked_files(source_root: &Path, worktree_root: &Path) -> Result<()> { - let paths = untracked_paths(source_root)?; - copy_untracked_files_at_paths(source_root, worktree_root, &paths) +fn tracked_paths(source_root: &Path) -> Result> { + let staged = git::bytes(source_root, &["diff", "--cached", "--name-only", "-z"])?; + let unstaged = git::bytes(source_root, &["diff", "--name-only", "-z"])?; + let mut paths = paths_from_nul_separated(&staged)?; + paths.extend(paths_from_nul_separated(&unstaged)?); + paths.sort(); + paths.dedup(); + Ok(paths) } -fn untracked_paths(source_root: &Path) -> Result> { - let output = git::bytes( - source_root, - &["ls-files", "--others", "--exclude-standard", "-z"], - )?; +fn paths_from_nul_separated(output: &[u8]) -> Result> { output .split(|byte| *byte == 0) .filter(|path| !path.is_empty()) @@ -147,6 +204,31 @@ fn untracked_paths(source_root: &Path) -> Result> { .collect() } +fn has_head(source_root: &Path) -> bool { + git::status(source_root, &["rev-parse", "--verify", "HEAD"]).is_ok() +} + +fn remove_file_if_present(source_root: &Path, relative_path: &Path, kind: &str) -> Result<()> { + match fs::remove_file(source_root.join(relative_path)) { + Ok(()) => Ok(()), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(err) => Err(err).with_context(|| { + format!( + "failed to remove moved {kind} path {} from source checkout", + relative_path.display() + ) + }), + } +} + +fn untracked_paths(source_root: &Path) -> Result> { + let output = git::bytes( + source_root, + &["ls-files", "--others", "--exclude-standard", "-z"], + )?; + paths_from_nul_separated(&output) +} + fn copy_untracked_files_at_paths( source_root: &Path, worktree_root: &Path, @@ -170,20 +252,6 @@ fn copy_untracked_files_at_paths( Ok(()) } -fn clean_source_after_move(source_root: &Path, untracked_paths: &[PathBuf]) -> Result<()> { - git::status(source_root, &["reset", "--hard", "HEAD"]) - .context("failed to clean tracked changes from source checkout after move")?; - for relative_path in untracked_paths { - fs::remove_file(source_root.join(relative_path)).with_context(|| { - format!( - "failed to remove moved untracked path {} from source checkout", - relative_path.display() - ) - })?; - } - Ok(()) -} - fn ensure_safe_relative_path(path: &Path) -> Result<()> { if path.is_absolute() { anyhow::bail!( diff --git a/codex-rs/worktree/src/lib.rs b/codex-rs/worktree/src/lib.rs index 3ca1a70f41..8c863028a0 100644 --- a/codex-rs/worktree/src/lib.rs +++ b/codex-rs/worktree/src/lib.rs @@ -16,11 +16,16 @@ pub use manager::ensure_worktree; pub use manager::list_worktrees; pub use manager::remove_worktree; pub use manager::resolve_worktree; +pub use metadata::WorktreeMetadata; +pub use metadata::WorktreeThreadMetadata; pub use metadata::bind_thread; pub use metadata::read_worktree_metadata; pub use metadata::write_worktree_metadata; pub use paths::codex_worktrees_root; pub use paths::is_managed_worktree_path; +pub use paths::repo_fingerprint; +pub use paths::sibling_worktree_git_root; +pub use paths::slugify_name; #[derive(Debug, Clone, PartialEq, Eq)] pub struct WorktreeRequest { diff --git a/codex-rs/worktree/src/manager.rs b/codex-rs/worktree/src/manager.rs index 1492b4ed51..21fba7988c 100644 --- a/codex-rs/worktree/src/manager.rs +++ b/codex-rs/worktree/src/manager.rs @@ -59,7 +59,7 @@ pub fn ensure_worktree(req: WorktreeRequest) -> Result { } let warnings = dirty::validate_dirty_policy_before_create(&repo.root, req.dirty_policy)?; - let base_ref = req.base_ref.as_deref().unwrap_or("HEAD"); + let has_head = git::status(&repo.root, &["rev-parse", "--verify", "HEAD"]).is_ok(); fs::create_dir_all( worktree_git_root .parent() @@ -75,7 +75,20 @@ pub fn ensure_worktree(req: WorktreeRequest) -> Result { &branch, ], )?; + } else if req.base_ref.is_none() && !has_head { + git::status( + &repo.root, + &[ + "worktree", + "add", + "--orphan", + "-b", + &branch, + &worktree_git_root.to_string_lossy(), + ], + )?; } else { + let base_ref = req.base_ref.as_deref().unwrap_or("HEAD"); git::status( &repo.root, &[ diff --git a/codex-rs/worktree/tests/git_backend.rs b/codex-rs/worktree/tests/git_backend.rs index 637fa0c939..fbfddb6955 100644 --- a/codex-rs/worktree/tests/git_backend.rs +++ b/codex-rs/worktree/tests/git_backend.rs @@ -206,6 +206,102 @@ fn move_all_transfers_dirty_state_and_cleans_source_checkout() -> anyhow::Result Ok(()) } +#[test] +fn move_tracked_transfers_tracked_state_and_leaves_untracked_files() -> anyhow::Result<()> { + let fixture = GitFixture::new()?; + fs::write(fixture.repo.path().join("staged.txt"), "staged changed\n")?; + run_git(fixture.repo.path(), &["add", "staged.txt"])?; + fs::write( + fixture.repo.path().join("unstaged.txt"), + "unstaged changed\n", + )?; + fs::write(fixture.repo.path().join("untracked.txt"), "untracked\n")?; + + let resolution = codex_worktree::ensure_worktree(WorktreeRequest { + codex_home: fixture.codex_home.path().to_path_buf(), + source_cwd: fixture.repo.path().to_path_buf(), + branch: "move-tracked".to_string(), + base_ref: /*base_ref*/ None, + dirty_policy: DirtyPolicy::MoveTracked, + })?; + + assert_eq!( + git( + &resolution.info.worktree_git_root, + &["diff", "--cached", "--name-only"] + )?, + "staged.txt" + ); + assert_eq!( + git(&resolution.info.worktree_git_root, &["diff", "--name-only"])?, + "unstaged.txt" + ); + assert!( + !resolution + .info + .worktree_git_root + .join("untracked.txt") + .exists() + ); + assert_eq!( + git(fixture.repo.path(), &["status", "--short"])?, + "?? untracked.txt" + ); + Ok(()) +} + +#[test] +fn creates_orphan_worktree_from_unborn_repo_without_base_ref() -> anyhow::Result<()> { + let fixture = GitFixture::new_unborn()?; + fs::write(fixture.repo.path().join("README.md"), "hello\n")?; + + let resolution = codex_worktree::ensure_worktree(WorktreeRequest { + codex_home: fixture.codex_home.path().to_path_buf(), + source_cwd: fixture.repo.path().to_path_buf(), + branch: "feature".to_string(), + base_ref: /*base_ref*/ None, + dirty_policy: DirtyPolicy::CopyAll, + })?; + + assert_eq!( + git( + &resolution.info.worktree_git_root, + &["status", "--short", "--branch"] + )?, + "## No commits yet on feature\n?? README.md" + ); + Ok(()) +} + +#[test] +fn move_all_cleans_unborn_source_checkout() -> anyhow::Result<()> { + let fixture = GitFixture::new_unborn()?; + fs::write(fixture.repo.path().join("staged.txt"), "staged\n")?; + run_git(fixture.repo.path(), &["add", "staged.txt"])?; + fs::write(fixture.repo.path().join("untracked.txt"), "untracked\n")?; + + let resolution = codex_worktree::ensure_worktree(WorktreeRequest { + codex_home: fixture.codex_home.path().to_path_buf(), + source_cwd: fixture.repo.path().to_path_buf(), + branch: "feature".to_string(), + base_ref: /*base_ref*/ None, + dirty_policy: DirtyPolicy::MoveAll, + })?; + + assert_eq!( + git( + &resolution.info.worktree_git_root, + &["status", "--short", "--branch"] + )?, + "## No commits yet on feature\nA staged.txt\n?? untracked.txt" + ); + assert_eq!( + git(fixture.repo.path(), &["status", "--short", "--branch"])?, + "## No commits yet on main" + ); + Ok(()) +} + #[test] fn refuses_sibling_path_collision_for_different_branch() -> anyhow::Result<()> { let fixture = GitFixture::new()?; @@ -292,6 +388,15 @@ impl GitFixture { run_git(repo.path(), &["commit", "-m", "initial"])?; Ok(Self { repo, codex_home }) } + + fn new_unborn() -> anyhow::Result { + let repo = TempDir::new()?; + let codex_home = TempDir::new()?; + run_git(repo.path(), &["init", "-b", "main"])?; + run_git(repo.path(), &["config", "user.email", "codex@example.com"])?; + run_git(repo.path(), &["config", "user.name", "Codex"])?; + Ok(Self { repo, codex_home }) + } } fn run_git(cwd: &Path, args: &[&str]) -> anyhow::Result<()> {