Skip to content

Commit 898bc1f

Browse files
ThlnkingzhoupengwuDimillian
authored
feat(settings): Custom workspace worktree folder can be configured (#549)
Co-authored-by: zhoupengwu <pengwu.zhou@xbongbong.com> Co-authored-by: Thomas Ricouard <ricouard77@gmail.com>
1 parent 1fc3044 commit 898bc1f

9 files changed

Lines changed: 285 additions & 54 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# Logs
22
logs
33
*.log
4+
**-lock.**
45
npm-debug.log*
56
yarn-debug.log*
67
yarn-error.log*

src-tauri/src/shared/workspaces_core/worktree.rs

Lines changed: 88 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,20 @@ where
134134
return Err("Cannot create a worktree from another worktree.".to_string());
135135
}
136136

137-
let worktree_root = data_dir.join("worktrees").join(&parent_entry.id);
137+
// Determine worktree root: per-workspace setting > global setting > default
138+
let worktree_root = if let Some(custom_folder) = &parent_entry.settings.worktrees_folder {
139+
PathBuf::from(custom_folder)
140+
} else {
141+
let global_folder = {
142+
let settings = app_settings.lock().await;
143+
settings.global_worktrees_folder.clone()
144+
};
145+
if let Some(global_folder) = global_folder {
146+
PathBuf::from(global_folder).join(&parent_entry.id)
147+
} else {
148+
data_dir.join("worktrees").join(&parent_entry.id)
149+
}
150+
};
138151
std::fs::create_dir_all(&worktree_root)
139152
.map_err(|err| format!("Failed to create worktree directory: {err}"))?;
140153

@@ -333,7 +346,7 @@ pub(crate) async fn rename_worktree_core<
333346
data_dir: &PathBuf,
334347
workspaces: &Mutex<HashMap<String, WorkspaceEntry>>,
335348
sessions: &Mutex<HashMap<String, Arc<WorkspaceSession>>>,
336-
_app_settings: &Mutex<AppSettings>,
349+
app_settings: &Mutex<AppSettings>,
337350
storage_path: &PathBuf,
338351
resolve_git_root: FResolveGitRoot,
339352
unique_branch_name: FUniqueBranch,
@@ -393,54 +406,102 @@ where
393406
return Err("Branch name is unchanged.".to_string());
394407
}
395408

396-
run_git_command(&parent_root, &["branch", "-m", &old_branch, &final_branch]).await?;
397-
398-
let worktree_root = data_dir.join("worktrees").join(&parent.id);
409+
// Use the same priority logic as add_worktree_core:
410+
// per-workspace setting > global setting > default
411+
let worktree_root = if let Some(custom_folder) = &parent.settings.worktrees_folder {
412+
PathBuf::from(custom_folder)
413+
} else {
414+
let global_folder = {
415+
let settings = app_settings.lock().await;
416+
settings.global_worktrees_folder.clone()
417+
};
418+
if let Some(global_folder) = global_folder {
419+
PathBuf::from(global_folder).join(&parent.id)
420+
} else {
421+
data_dir.join("worktrees").join(&parent.id)
422+
}
423+
};
399424
std::fs::create_dir_all(&worktree_root)
400425
.map_err(|err| format!("Failed to create worktree directory: {err}"))?;
401426

402427
let safe_name = sanitize_worktree_name(&final_branch);
403428
let current_path = PathBuf::from(&entry.path);
404429
let next_path = unique_worktree_path_for_rename(&worktree_root, &safe_name, &current_path)?;
405430
let next_path_string = next_path.to_string_lossy().to_string();
406-
if next_path_string != entry.path {
431+
let old_path_string = entry.path.clone();
432+
433+
run_git_command(&parent_root, &["branch", "-m", &old_branch, &final_branch]).await?;
434+
435+
let mut moved_worktree = false;
436+
if next_path_string != old_path_string {
407437
if let Err(error) = run_git_command(
408438
&parent_root,
409-
&["worktree", "move", &entry.path, &next_path_string],
439+
&["worktree", "move", &old_path_string, &next_path_string],
410440
)
411441
.await
412442
{
413443
let _ =
414444
run_git_command(&parent_root, &["branch", "-m", &final_branch, &old_branch]).await;
415445
return Err(error);
416446
}
447+
moved_worktree = true;
417448
}
418449

419-
let (entry_snapshot, list) = {
450+
let update_result: Result<(WorkspaceEntry, WorkspaceEntry, Vec<WorkspaceEntry>), String> = {
420451
let mut workspaces = workspaces.lock().await;
421-
let entry = match workspaces.get_mut(&id) {
422-
Some(entry) => entry,
423-
None => return Err("workspace not found".to_string()),
424-
};
425-
if entry.name.trim() == old_branch {
426-
entry.name = final_branch.clone();
427-
}
428-
entry.path = next_path_string.clone();
429-
match entry.worktree.as_mut() {
430-
Some(worktree) => {
431-
worktree.branch = final_branch.clone();
452+
if let Some(entry) = workspaces.get_mut(&id) {
453+
let old_snapshot = entry.clone();
454+
if entry.name.trim() == old_branch {
455+
entry.name = final_branch.clone();
432456
}
433-
None => {
434-
entry.worktree = Some(WorktreeInfo {
435-
branch: final_branch.clone(),
436-
});
457+
entry.path = next_path_string.clone();
458+
match entry.worktree.as_mut() {
459+
Some(worktree) => {
460+
worktree.branch = final_branch.clone();
461+
}
462+
None => {
463+
entry.worktree = Some(WorktreeInfo {
464+
branch: final_branch.clone(),
465+
});
466+
}
437467
}
468+
let snapshot = entry.clone();
469+
let list: Vec<_> = workspaces.values().cloned().collect();
470+
Ok((old_snapshot, snapshot, list))
471+
} else {
472+
Err("workspace not found".to_string())
473+
}
474+
};
475+
let (old_snapshot, entry_snapshot, list) = match update_result {
476+
Ok(value) => value,
477+
Err(error) => {
478+
if moved_worktree {
479+
let _ = run_git_command(
480+
&parent_root,
481+
&["worktree", "move", &next_path_string, &old_path_string],
482+
)
483+
.await;
484+
}
485+
let _ =
486+
run_git_command(&parent_root, &["branch", "-m", &final_branch, &old_branch]).await;
487+
return Err(error);
438488
}
439-
let snapshot = entry.clone();
440-
let list: Vec<_> = workspaces.values().cloned().collect();
441-
(snapshot, list)
442489
};
443-
write_workspaces(storage_path, &list)?;
490+
if let Err(error) = write_workspaces(storage_path, &list) {
491+
if moved_worktree {
492+
let _ = run_git_command(
493+
&parent_root,
494+
&["worktree", "move", &next_path_string, &old_path_string],
495+
)
496+
.await;
497+
}
498+
let _ = run_git_command(&parent_root, &["branch", "-m", &final_branch, &old_branch]).await;
499+
let mut workspaces = workspaces.lock().await;
500+
if let Some(entry) = workspaces.get_mut(&id) {
501+
*entry = old_snapshot;
502+
}
503+
return Err(error);
504+
}
444505

445506
if let Some(session) = sessions.lock().await.get(&entry_snapshot.id).cloned() {
446507
session

src-tauri/src/types.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,8 @@ pub(crate) struct WorkspaceSettings {
329329
pub(crate) launch_scripts: Option<Vec<LaunchScriptEntry>>,
330330
#[serde(default, rename = "worktreeSetupScript")]
331331
pub(crate) worktree_setup_script: Option<String>,
332+
#[serde(default, rename = "worktreesFolder")]
333+
pub(crate) worktrees_folder: Option<String>,
332334
}
333335

334336
#[derive(Debug, Serialize, Deserialize, Clone)]
@@ -635,6 +637,8 @@ pub(crate) struct AppSettings {
635637
pub(crate) composer_code_block_copy_use_modifier: bool,
636638
#[serde(default = "default_workspace_groups", rename = "workspaceGroups")]
637639
pub(crate) workspace_groups: Vec<WorkspaceGroup>,
640+
#[serde(default, rename = "globalWorktreesFolder")]
641+
pub(crate) global_worktrees_folder: Option<String>,
638642
#[serde(default = "default_open_app_targets", rename = "openAppTargets")]
639643
pub(crate) open_app_targets: Vec<OpenAppTarget>,
640644
#[serde(default = "default_selected_open_app_id", rename = "selectedOpenAppId")]
@@ -1182,6 +1186,7 @@ impl Default for AppSettings {
11821186
composer_list_continuation: default_composer_list_continuation(),
11831187
composer_code_block_copy_use_modifier: default_composer_code_block_copy_use_modifier(),
11841188
workspace_groups: default_workspace_groups(),
1189+
global_worktrees_folder: None,
11851190
open_app_targets: default_open_app_targets(),
11861191
selected_open_app_id: default_selected_open_app_id(),
11871192
}

src-tauri/src/workspaces/tests.rs

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use std::collections::HashMap;
22
use std::future::Future;
33
use std::path::PathBuf;
4-
use std::sync::Arc;
4+
use std::sync::{Arc, Mutex as StdMutex};
55

66
use super::settings::{apply_workspace_settings_update, sort_workspaces};
77
use super::worktree::{
@@ -56,6 +56,7 @@ fn workspace_with_id_and_kind(
5656
launch_script: None,
5757
launch_scripts: None,
5858
worktree_setup_script: None,
59+
worktrees_folder: None,
5960
},
6061
}
6162
}
@@ -390,6 +391,95 @@ fn rename_worktree_updates_name_when_unmodified() {
390391
});
391392
}
392393

394+
#[test]
395+
fn rename_worktree_validates_worktree_root_before_branch_rename() {
396+
run_async(async {
397+
let temp_dir = std::env::temp_dir().join(format!("codex-monitor-test-{}", Uuid::new_v4()));
398+
let repo_path = temp_dir.join("repo");
399+
std::fs::create_dir_all(&repo_path).expect("create repo path");
400+
let worktree_path = temp_dir.join("worktrees").join("parent").join("old");
401+
std::fs::create_dir_all(&worktree_path).expect("create worktree path");
402+
403+
let invalid_root = temp_dir.join("not-a-directory");
404+
std::fs::write(&invalid_root, "x").expect("create invalid root file");
405+
406+
let mut parent_settings = WorkspaceSettings::default();
407+
parent_settings.worktrees_folder = Some(invalid_root.to_string_lossy().to_string());
408+
let parent = WorkspaceEntry {
409+
id: "parent".to_string(),
410+
name: "Parent".to_string(),
411+
path: repo_path.to_string_lossy().to_string(),
412+
kind: WorkspaceKind::Main,
413+
parent_id: None,
414+
worktree: None,
415+
settings: parent_settings,
416+
};
417+
let worktree = WorkspaceEntry {
418+
id: "wt-3".to_string(),
419+
name: "feature/old".to_string(),
420+
path: worktree_path.to_string_lossy().to_string(),
421+
kind: WorkspaceKind::Worktree,
422+
parent_id: Some(parent.id.clone()),
423+
worktree: Some(WorktreeInfo {
424+
branch: "feature/old".to_string(),
425+
}),
426+
settings: WorkspaceSettings::default(),
427+
};
428+
let workspaces = Mutex::new(HashMap::from([
429+
(parent.id.clone(), parent.clone()),
430+
(worktree.id.clone(), worktree.clone()),
431+
]));
432+
let sessions: Mutex<HashMap<String, Arc<WorkspaceSession>>> = Mutex::new(HashMap::new());
433+
let app_settings = Mutex::new(AppSettings::default());
434+
let storage_path = temp_dir.join("workspaces.json");
435+
436+
let calls: Arc<StdMutex<Vec<Vec<String>>>> = Arc::new(StdMutex::new(Vec::new()));
437+
let result = rename_worktree_core(
438+
worktree.id.clone(),
439+
"feature/new".to_string(),
440+
&temp_dir,
441+
&workspaces,
442+
&sessions,
443+
&app_settings,
444+
&storage_path,
445+
|_| Ok(repo_path.clone()),
446+
|_root, branch| {
447+
let branch = branch.to_string();
448+
async move { Ok(branch) }
449+
},
450+
|value| sanitize_worktree_name(value),
451+
|_, _, current| Ok(current.to_path_buf()),
452+
|_root, args| {
453+
let calls = calls.clone();
454+
let args: Vec<String> = args.iter().map(|value| value.to_string()).collect();
455+
async move {
456+
calls
457+
.lock()
458+
.expect("lock")
459+
.push(args);
460+
Ok(())
461+
}
462+
},
463+
|_entry, _default_bin, _codex_args, _codex_home| async move {
464+
Err("spawn not expected".to_string())
465+
},
466+
)
467+
.await;
468+
469+
let error = result.expect_err("expected invalid worktree root to fail");
470+
assert!(error.contains("Failed to create worktree directory"));
471+
assert!(calls.lock().expect("lock").is_empty());
472+
473+
let stored = workspaces.lock().await;
474+
let entry = stored.get(&worktree.id).expect("stored entry");
475+
assert_eq!(
476+
entry.worktree.as_ref().map(|worktree| worktree.branch.as_str()),
477+
Some("feature/old")
478+
);
479+
assert_eq!(entry.path, worktree.path);
480+
});
481+
}
482+
393483
#[test]
394484
fn remove_workspace_succeeds_when_parent_repo_folder_is_missing() {
395485
run_async(async {

src/features/settings/components/SettingsView.test.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@ const baseSettings: AppSettings = {
151151
},
152152
],
153153
selectedOpenAppId: "vscode",
154+
globalWorktreesFolder: null,
154155
};
155156

156157
const createDoctorResult = () => ({
@@ -689,6 +690,7 @@ describe("SettingsView Environments", () => {
689690
await waitFor(() => {
690691
expect(onUpdateWorkspaceSettings).toHaveBeenCalledWith("w1", {
691692
worktreeSetupScript: "echo updated",
693+
worktreesFolder: null,
692694
});
693695
});
694696
});
@@ -704,6 +706,7 @@ describe("SettingsView Environments", () => {
704706
await waitFor(() => {
705707
expect(onUpdateWorkspaceSettings).toHaveBeenCalledWith("w1", {
706708
worktreeSetupScript: null,
709+
worktreesFolder: null,
707710
});
708711
});
709712
});

0 commit comments

Comments
 (0)