mirror of
https://github.com/openai/codex.git
synced 2026-05-17 09:43:19 +00:00
feat(worktree): fill remaining worktree gaps
This commit is contained in:
@@ -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"])
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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(_));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(_))
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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))]
|
||||
|
||||
868
codex-rs/tui/src/remote_worktree.rs
Normal file
868
codex-rs/tui/src/remote_worktree.rs
Normal 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(())
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -8,5 +8,6 @@ pub enum WorktreeDirtyCliArg {
|
||||
Ignore,
|
||||
CopyTracked,
|
||||
CopyAll,
|
||||
MoveTracked,
|
||||
MoveAll,
|
||||
}
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
&[
|
||||
|
||||
@@ -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<()> {
|
||||
|
||||
Reference in New Issue
Block a user