feat(worktree): fill remaining worktree gaps

This commit is contained in:
Felipe Coury
2026-05-09 12:52:13 -03:00
parent 6e460f31cd
commit 93317c151d
15 changed files with 1435 additions and 143 deletions

View File

@@ -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<String>,
/// 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"])

View File

@@ -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 => {

View File

@@ -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<Vec<WorktreeInfo>, 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<String>,
dirty_policy: Option<DirtyPolicy>,
) {
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<WorktreeResolution, String>,
) {
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<Vec<WorktreeInfo>> {
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<Vec<WorktreeInfo>> {
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<codex_worktree::DirtyState> {
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<String>,
) {
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<String>,
) {
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(_));

View File

@@ -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,

View File

@@ -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<PathBuf>) {
self.remote_cwd_override = remote_cwd_override;
}
pub(crate) fn is_remote(&self) -> bool {
matches!(self.client, AppServerClient::Remote(_))
}

View File

@@ -31,6 +31,7 @@ pub(crate) struct CustomPromptView {
placeholder: String,
context_label: Option<String>,
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<String>,
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);
}

View File

@@ -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))]

View File

@@ -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<Vec<WorktreeInfo>> {
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<DirtyState> {
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<WorktreeResolution> {
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<WorktreeRemoveResult> {
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<Self> {
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<String>,
repo: &RemoteSourceRepo,
) -> Result<WorktreeInfo> {
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<DirtyState> {
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<Vec<String>> {
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<PathBuf>,
untracked_paths: Vec<PathBuf>,
}
impl RemoteTransferPlan {
async fn capture(runner: &WorkspaceCommandRunner, source_root: &Path) -> Result<Self> {
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<Vec<PathBuf>> {
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<Vec<PathBuf>> {
Ok(paths_from_nul_separated(
&git_stdout(
runner,
root,
&["ls-files", "--others", "--exclude-standard", "-z"],
)
.await?,
))
}
fn paths_from_nul_separated(output: &str) -> Vec<PathBuf> {
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<Option<WorktreeMetadata>> {
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<T: serde::Serialize>(
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<PathBuf> {
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<String>,
}
fn parse_worktree_list(output: &str) -> Vec<GitWorktreeEntry> {
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<String> {
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<crate::workspace_command::WorkspaceCommandOutput> {
let argv = std::iter::once("git")
.chain(args.iter().copied())
.collect::<Vec<_>>();
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<crate::workspace_command::WorkspaceCommandOutput> {
git_stdout_result(runner, cwd, args).await
}
fn absolute_path(path: &Path) -> Result<AbsolutePathBuf> {
AbsolutePathBuf::from_absolute_path(path).map_err(Into::into)
}
async fn path_exists(request_handle: &AppServerRequestHandle, path: &Path) -> Result<bool> {
let result: Result<FsGetMetadataResponse, _> = 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<Vec<u8>> {
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(())
}

View File

@@ -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

View File

@@ -236,9 +236,10 @@ fn parse_dirty_policy(value: &str) -> Result<DirtyPolicy, String> {
"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!(

View File

@@ -8,5 +8,6 @@ pub enum WorktreeDirtyCliArg {
Ignore,
CopyTracked,
CopyAll,
MoveTracked,
MoveAll,
}

View File

@@ -16,9 +16,18 @@ pub enum DirtyPolicy {
Ignore,
CopyTracked,
CopyAll,
MoveTracked,
MoveAll,
}
#[derive(Debug)]
struct TransferPlan {
staged_diff: Vec<u8>,
unstaged_diff: Vec<u8>,
tracked_paths: Vec<PathBuf>,
untracked_paths: Vec<PathBuf>,
}
#[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<T>() -> Result<T> {
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<Self> {
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<Vec<PathBuf>> {
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<Vec<PathBuf>> {
let output = git::bytes(
source_root,
&["ls-files", "--others", "--exclude-standard", "-z"],
)?;
fn paths_from_nul_separated(output: &[u8]) -> Result<Vec<PathBuf>> {
output
.split(|byte| *byte == 0)
.filter(|path| !path.is_empty())
@@ -147,6 +204,31 @@ fn untracked_paths(source_root: &Path) -> Result<Vec<PathBuf>> {
.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<Vec<PathBuf>> {
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!(

View File

@@ -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 {

View File

@@ -59,7 +59,7 @@ pub fn ensure_worktree(req: WorktreeRequest) -> Result<WorktreeResolution> {
}
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<WorktreeResolution> {
&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,
&[

View File

@@ -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<Self> {
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<()> {