Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Logs
logs
*.log
**-lock.**
npm-debug.log*
yarn-debug.log*
yarn-error.log*
Expand Down
115 changes: 88 additions & 27 deletions src-tauri/src/shared/workspaces_core/worktree.rs
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,20 @@ where
return Err("Cannot create a worktree from another worktree.".to_string());
}

let worktree_root = data_dir.join("worktrees").join(&parent_entry.id);
// Determine worktree root: per-workspace setting > global setting > default
let worktree_root = if let Some(custom_folder) = &parent_entry.settings.worktrees_folder {
PathBuf::from(custom_folder)
} else {
let global_folder = {
let settings = app_settings.lock().await;
settings.global_worktrees_folder.clone()
};
if let Some(global_folder) = global_folder {
PathBuf::from(global_folder).join(&parent_entry.id)
} else {
data_dir.join("worktrees").join(&parent_entry.id)
}
};
std::fs::create_dir_all(&worktree_root)
.map_err(|err| format!("Failed to create worktree directory: {err}"))?;

Expand Down Expand Up @@ -333,7 +346,7 @@ pub(crate) async fn rename_worktree_core<
data_dir: &PathBuf,
workspaces: &Mutex<HashMap<String, WorkspaceEntry>>,
sessions: &Mutex<HashMap<String, Arc<WorkspaceSession>>>,
_app_settings: &Mutex<AppSettings>,
app_settings: &Mutex<AppSettings>,
storage_path: &PathBuf,
resolve_git_root: FResolveGitRoot,
unique_branch_name: FUniqueBranch,
Expand Down Expand Up @@ -393,54 +406,102 @@ where
return Err("Branch name is unchanged.".to_string());
}

run_git_command(&parent_root, &["branch", "-m", &old_branch, &final_branch]).await?;

let worktree_root = data_dir.join("worktrees").join(&parent.id);
// Use the same priority logic as add_worktree_core:
// per-workspace setting > global setting > default
let worktree_root = if let Some(custom_folder) = &parent.settings.worktrees_folder {
PathBuf::from(custom_folder)
} else {
let global_folder = {
let settings = app_settings.lock().await;
settings.global_worktrees_folder.clone()
};
if let Some(global_folder) = global_folder {
PathBuf::from(global_folder).join(&parent.id)
} else {
data_dir.join("worktrees").join(&parent.id)
}
};
std::fs::create_dir_all(&worktree_root)
.map_err(|err| format!("Failed to create worktree directory: {err}"))?;

let safe_name = sanitize_worktree_name(&final_branch);
let current_path = PathBuf::from(&entry.path);
let next_path = unique_worktree_path_for_rename(&worktree_root, &safe_name, &current_path)?;
let next_path_string = next_path.to_string_lossy().to_string();
if next_path_string != entry.path {
let old_path_string = entry.path.clone();

run_git_command(&parent_root, &["branch", "-m", &old_branch, &final_branch]).await?;

let mut moved_worktree = false;
if next_path_string != old_path_string {
if let Err(error) = run_git_command(
&parent_root,
&["worktree", "move", &entry.path, &next_path_string],
&["worktree", "move", &old_path_string, &next_path_string],
)
.await
{
let _ =
run_git_command(&parent_root, &["branch", "-m", &final_branch, &old_branch]).await;
return Err(error);
}
moved_worktree = true;
}

let (entry_snapshot, list) = {
let update_result: Result<(WorkspaceEntry, WorkspaceEntry, Vec<WorkspaceEntry>), String> = {
let mut workspaces = workspaces.lock().await;
let entry = match workspaces.get_mut(&id) {
Some(entry) => entry,
None => return Err("workspace not found".to_string()),
};
if entry.name.trim() == old_branch {
entry.name = final_branch.clone();
}
entry.path = next_path_string.clone();
match entry.worktree.as_mut() {
Some(worktree) => {
worktree.branch = final_branch.clone();
if let Some(entry) = workspaces.get_mut(&id) {
let old_snapshot = entry.clone();
if entry.name.trim() == old_branch {
entry.name = final_branch.clone();
}
None => {
entry.worktree = Some(WorktreeInfo {
branch: final_branch.clone(),
});
entry.path = next_path_string.clone();
match entry.worktree.as_mut() {
Some(worktree) => {
worktree.branch = final_branch.clone();
}
None => {
entry.worktree = Some(WorktreeInfo {
branch: final_branch.clone(),
});
}
}
let snapshot = entry.clone();
let list: Vec<_> = workspaces.values().cloned().collect();
Ok((old_snapshot, snapshot, list))
} else {
Err("workspace not found".to_string())
}
};
let (old_snapshot, entry_snapshot, list) = match update_result {
Ok(value) => value,
Err(error) => {
if moved_worktree {
let _ = run_git_command(
&parent_root,
&["worktree", "move", &next_path_string, &old_path_string],
)
.await;
}
let _ =
run_git_command(&parent_root, &["branch", "-m", &final_branch, &old_branch]).await;
return Err(error);
}
let snapshot = entry.clone();
let list: Vec<_> = workspaces.values().cloned().collect();
(snapshot, list)
};
write_workspaces(storage_path, &list)?;
if let Err(error) = write_workspaces(storage_path, &list) {
if moved_worktree {
let _ = run_git_command(
&parent_root,
&["worktree", "move", &next_path_string, &old_path_string],
)
.await;
}
let _ = run_git_command(&parent_root, &["branch", "-m", &final_branch, &old_branch]).await;
let mut workspaces = workspaces.lock().await;
if let Some(entry) = workspaces.get_mut(&id) {
*entry = old_snapshot;
}
return Err(error);
}

if let Some(session) = sessions.lock().await.get(&entry_snapshot.id).cloned() {
session
Expand Down
5 changes: 5 additions & 0 deletions src-tauri/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,8 @@ pub(crate) struct WorkspaceSettings {
pub(crate) launch_scripts: Option<Vec<LaunchScriptEntry>>,
#[serde(default, rename = "worktreeSetupScript")]
pub(crate) worktree_setup_script: Option<String>,
#[serde(default, rename = "worktreesFolder")]
pub(crate) worktrees_folder: Option<String>,
}

#[derive(Debug, Serialize, Deserialize, Clone)]
Expand Down Expand Up @@ -635,6 +637,8 @@ pub(crate) struct AppSettings {
pub(crate) composer_code_block_copy_use_modifier: bool,
#[serde(default = "default_workspace_groups", rename = "workspaceGroups")]
pub(crate) workspace_groups: Vec<WorkspaceGroup>,
#[serde(default, rename = "globalWorktreesFolder")]
pub(crate) global_worktrees_folder: Option<String>,
#[serde(default = "default_open_app_targets", rename = "openAppTargets")]
pub(crate) open_app_targets: Vec<OpenAppTarget>,
#[serde(default = "default_selected_open_app_id", rename = "selectedOpenAppId")]
Expand Down Expand Up @@ -1182,6 +1186,7 @@ impl Default for AppSettings {
composer_list_continuation: default_composer_list_continuation(),
composer_code_block_copy_use_modifier: default_composer_code_block_copy_use_modifier(),
workspace_groups: default_workspace_groups(),
global_worktrees_folder: None,
open_app_targets: default_open_app_targets(),
selected_open_app_id: default_selected_open_app_id(),
}
Expand Down
92 changes: 91 additions & 1 deletion src-tauri/src/workspaces/tests.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use std::collections::HashMap;
use std::future::Future;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::{Arc, Mutex as StdMutex};

use super::settings::{apply_workspace_settings_update, sort_workspaces};
use super::worktree::{
Expand Down Expand Up @@ -56,6 +56,7 @@ fn workspace_with_id_and_kind(
launch_script: None,
launch_scripts: None,
worktree_setup_script: None,
worktrees_folder: None,
},
}
}
Expand Down Expand Up @@ -390,6 +391,95 @@ fn rename_worktree_updates_name_when_unmodified() {
});
}

#[test]
fn rename_worktree_validates_worktree_root_before_branch_rename() {
run_async(async {
let temp_dir = std::env::temp_dir().join(format!("codex-monitor-test-{}", Uuid::new_v4()));
let repo_path = temp_dir.join("repo");
std::fs::create_dir_all(&repo_path).expect("create repo path");
let worktree_path = temp_dir.join("worktrees").join("parent").join("old");
std::fs::create_dir_all(&worktree_path).expect("create worktree path");

let invalid_root = temp_dir.join("not-a-directory");
std::fs::write(&invalid_root, "x").expect("create invalid root file");

let mut parent_settings = WorkspaceSettings::default();
parent_settings.worktrees_folder = Some(invalid_root.to_string_lossy().to_string());
let parent = WorkspaceEntry {
id: "parent".to_string(),
name: "Parent".to_string(),
path: repo_path.to_string_lossy().to_string(),
kind: WorkspaceKind::Main,
parent_id: None,
worktree: None,
settings: parent_settings,
};
let worktree = WorkspaceEntry {
id: "wt-3".to_string(),
name: "feature/old".to_string(),
path: worktree_path.to_string_lossy().to_string(),
kind: WorkspaceKind::Worktree,
parent_id: Some(parent.id.clone()),
worktree: Some(WorktreeInfo {
branch: "feature/old".to_string(),
}),
settings: WorkspaceSettings::default(),
};
let workspaces = Mutex::new(HashMap::from([
(parent.id.clone(), parent.clone()),
(worktree.id.clone(), worktree.clone()),
]));
let sessions: Mutex<HashMap<String, Arc<WorkspaceSession>>> = Mutex::new(HashMap::new());
let app_settings = Mutex::new(AppSettings::default());
let storage_path = temp_dir.join("workspaces.json");

let calls: Arc<StdMutex<Vec<Vec<String>>>> = Arc::new(StdMutex::new(Vec::new()));
let result = rename_worktree_core(
worktree.id.clone(),
"feature/new".to_string(),
&temp_dir,
&workspaces,
&sessions,
&app_settings,
&storage_path,
|_| Ok(repo_path.clone()),
|_root, branch| {
let branch = branch.to_string();
async move { Ok(branch) }
},
|value| sanitize_worktree_name(value),
|_, _, current| Ok(current.to_path_buf()),
|_root, args| {
let calls = calls.clone();
let args: Vec<String> = args.iter().map(|value| value.to_string()).collect();
async move {
calls
.lock()
.expect("lock")
.push(args);
Ok(())
}
},
|_entry, _default_bin, _codex_args, _codex_home| async move {
Err("spawn not expected".to_string())
},
)
.await;

let error = result.expect_err("expected invalid worktree root to fail");
assert!(error.contains("Failed to create worktree directory"));
assert!(calls.lock().expect("lock").is_empty());

let stored = workspaces.lock().await;
let entry = stored.get(&worktree.id).expect("stored entry");
assert_eq!(
entry.worktree.as_ref().map(|worktree| worktree.branch.as_str()),
Some("feature/old")
);
assert_eq!(entry.path, worktree.path);
});
}

#[test]
fn remove_workspace_succeeds_when_parent_repo_folder_is_missing() {
run_async(async {
Expand Down
3 changes: 3 additions & 0 deletions src/features/settings/components/SettingsView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ const baseSettings: AppSettings = {
},
],
selectedOpenAppId: "vscode",
globalWorktreesFolder: null,
};

const createDoctorResult = () => ({
Expand Down Expand Up @@ -689,6 +690,7 @@ describe("SettingsView Environments", () => {
await waitFor(() => {
expect(onUpdateWorkspaceSettings).toHaveBeenCalledWith("w1", {
worktreeSetupScript: "echo updated",
worktreesFolder: null,
});
});
});
Expand All @@ -704,6 +706,7 @@ describe("SettingsView Environments", () => {
await waitFor(() => {
expect(onUpdateWorkspaceSettings).toHaveBeenCalledWith("w1", {
worktreeSetupScript: null,
worktreesFolder: null,
});
});
});
Expand Down
Loading
Loading