diff --git a/.gitignore b/.gitignore index 05b62d7c2..cebd4d089 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Logs logs *.log +**-lock.** npm-debug.log* yarn-debug.log* yarn-error.log* diff --git a/src-tauri/src/shared/workspaces_core/worktree.rs b/src-tauri/src/shared/workspaces_core/worktree.rs index 58d808e12..222111596 100644 --- a/src-tauri/src/shared/workspaces_core/worktree.rs +++ b/src-tauri/src/shared/workspaces_core/worktree.rs @@ -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}"))?; @@ -333,7 +346,7 @@ pub(crate) async fn rename_worktree_core< data_dir: &PathBuf, workspaces: &Mutex>, sessions: &Mutex>>, - _app_settings: &Mutex, + app_settings: &Mutex, storage_path: &PathBuf, resolve_git_root: FResolveGitRoot, unique_branch_name: FUniqueBranch, @@ -393,9 +406,21 @@ 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}"))?; @@ -403,10 +428,15 @@ where let current_path = PathBuf::from(&entry.path); let next_path = unique_worktree_path_for_rename(&worktree_root, &safe_name, ¤t_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 { @@ -414,33 +444,64 @@ where 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), 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 diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index cbb8afbaa..fdc37af43 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -329,6 +329,8 @@ pub(crate) struct WorkspaceSettings { pub(crate) launch_scripts: Option>, #[serde(default, rename = "worktreeSetupScript")] pub(crate) worktree_setup_script: Option, + #[serde(default, rename = "worktreesFolder")] + pub(crate) worktrees_folder: Option, } #[derive(Debug, Serialize, Deserialize, Clone)] @@ -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, + #[serde(default, rename = "globalWorktreesFolder")] + pub(crate) global_worktrees_folder: Option, #[serde(default = "default_open_app_targets", rename = "openAppTargets")] pub(crate) open_app_targets: Vec, #[serde(default = "default_selected_open_app_id", rename = "selectedOpenAppId")] @@ -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(), } diff --git a/src-tauri/src/workspaces/tests.rs b/src-tauri/src/workspaces/tests.rs index 04fe3ff19..01311ae70 100644 --- a/src-tauri/src/workspaces/tests.rs +++ b/src-tauri/src/workspaces/tests.rs @@ -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::{ @@ -56,6 +56,7 @@ fn workspace_with_id_and_kind( launch_script: None, launch_scripts: None, worktree_setup_script: None, + worktrees_folder: None, }, } } @@ -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>> = Mutex::new(HashMap::new()); + let app_settings = Mutex::new(AppSettings::default()); + let storage_path = temp_dir.join("workspaces.json"); + + let calls: Arc>>> = 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 = 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 { diff --git a/src/features/settings/components/sections/SettingsEnvironmentsSection.tsx b/src/features/settings/components/sections/SettingsEnvironmentsSection.tsx index a04312c67..8f6b253cb 100644 --- a/src/features/settings/components/sections/SettingsEnvironmentsSection.tsx +++ b/src/features/settings/components/sections/SettingsEnvironmentsSection.tsx @@ -11,8 +11,12 @@ type SettingsEnvironmentsSectionProps = { environmentDraftScript: string; environmentSavedScript: string | null; environmentDirty: boolean; + worktreesFolderDraft: string; + worktreesFolderSaved: string | null; + worktreesFolderDirty: boolean; onSetEnvironmentWorkspaceId: Dispatch>; onSetEnvironmentDraftScript: Dispatch>; + onSetWorktreesFolderDraft: Dispatch>; onSaveEnvironmentSetup: () => Promise; }; @@ -24,14 +28,20 @@ export function SettingsEnvironmentsSection({ environmentDraftScript, environmentSavedScript, environmentDirty, + worktreesFolderDraft, + worktreesFolderSaved, + worktreesFolderDirty, onSetEnvironmentWorkspaceId, onSetEnvironmentDraftScript, + onSetWorktreesFolderDraft, onSaveEnvironmentSetup, }: SettingsEnvironmentsSectionProps) { + const hasAnyChanges = environmentDirty || worktreesFolderDirty; + return ( {mainWorkspaces.length === 0 ? (
No projects yet.
@@ -116,12 +126,57 @@ export function SettingsEnvironmentsSection({ onClick={() => { void onSaveEnvironmentSetup(); }} - disabled={environmentSaving || !environmentDirty} + disabled={environmentSaving || !hasAnyChanges} > {environmentSaving ? "Saving..." : "Save"} + +
+ +
+ Custom location for worktrees. Leave empty to use the default location. +
+
+ onSetWorktreesFolderDraft(event.target.value)} + placeholder="/path/to/worktrees" + disabled={environmentSaving} + /> + +
+
)}
diff --git a/src/features/settings/hooks/useSettingsEnvironmentsSection.ts b/src/features/settings/hooks/useSettingsEnvironmentsSection.ts index 118ba5251..9d04f10ec 100644 --- a/src/features/settings/hooks/useSettingsEnvironmentsSection.ts +++ b/src/features/settings/hooks/useSettingsEnvironmentsSection.ts @@ -19,8 +19,12 @@ export type SettingsEnvironmentsSectionProps = { environmentDraftScript: string; environmentSavedScript: string | null; environmentDirty: boolean; + worktreesFolderDraft: string; + worktreesFolderSaved: string | null; + worktreesFolderDirty: boolean; onSetEnvironmentWorkspaceId: Dispatch>; onSetEnvironmentDraftScript: Dispatch>; + onSetWorktreesFolderDraft: Dispatch>; onSaveEnvironmentSetup: () => Promise; }; @@ -28,28 +32,20 @@ export const useSettingsEnvironmentsSection = ({ mainWorkspaces, onUpdateWorkspaceSettings, }: UseSettingsEnvironmentsSectionArgs): SettingsEnvironmentsSectionProps => { - const [environmentWorkspaceId, setEnvironmentWorkspaceId] = useState( - null, - ); + const [environmentWorkspaceId, setEnvironmentWorkspaceId] = useState(null); const [environmentDraftScript, setEnvironmentDraftScript] = useState(""); - const [environmentSavedScript, setEnvironmentSavedScript] = useState( - null, - ); - const [environmentLoadedWorkspaceId, setEnvironmentLoadedWorkspaceId] = useState< - string | null - >(null); + const [environmentSavedScript, setEnvironmentSavedScript] = useState(null); + const [environmentLoadedWorkspaceId, setEnvironmentLoadedWorkspaceId] = useState(null); const [environmentError, setEnvironmentError] = useState(null); const [environmentSaving, setEnvironmentSaving] = useState(false); + const [worktreesFolderDraft, setWorktreesFolderDraft] = useState(""); + const [worktreesFolderSaved, setWorktreesFolderSaved] = useState(null); const environmentWorkspace = useMemo(() => { - if (mainWorkspaces.length === 0) { - return null; - } + if (mainWorkspaces.length === 0) return null; if (environmentWorkspaceId) { const found = mainWorkspaces.find((workspace) => workspace.id === environmentWorkspaceId); - if (found) { - return found; - } + if (found) return found; } return mainWorkspaces[0] ?? null; }, [environmentWorkspaceId, mainWorkspaces]); @@ -58,11 +54,16 @@ export const useSettingsEnvironmentsSection = ({ return normalizeWorktreeSetupScript(environmentWorkspace?.settings.worktreeSetupScript); }, [environmentWorkspace?.settings.worktreeSetupScript]); + const worktreesFolderFromWorkspace = useMemo(() => { + return environmentWorkspace?.settings.worktreesFolder ?? null; + }, [environmentWorkspace?.settings.worktreesFolder]); + const environmentDraftNormalized = useMemo(() => { return normalizeWorktreeSetupScript(environmentDraftScript); }, [environmentDraftScript]); const environmentDirty = environmentDraftNormalized !== environmentSavedScript; + const worktreesFolderDirty = (worktreesFolderDraft.trim() || null) !== worktreesFolderSaved; useEffect(() => { if (!environmentWorkspace) { @@ -72,53 +73,61 @@ export const useSettingsEnvironmentsSection = ({ setEnvironmentDraftScript(""); setEnvironmentError(null); setEnvironmentSaving(false); + setWorktreesFolderDraft(""); + setWorktreesFolderSaved(null); return; } - if (environmentWorkspaceId !== environmentWorkspace.id) { setEnvironmentWorkspaceId(environmentWorkspace.id); } }, [environmentWorkspace, environmentWorkspaceId]); useEffect(() => { - if (!environmentWorkspace) { - return; - } - + if (!environmentWorkspace) return; if (environmentLoadedWorkspaceId !== environmentWorkspace.id) { setEnvironmentLoadedWorkspaceId(environmentWorkspace.id); setEnvironmentSavedScript(environmentSavedScriptFromWorkspace); setEnvironmentDraftScript(environmentSavedScriptFromWorkspace ?? ""); + setWorktreesFolderSaved(worktreesFolderFromWorkspace); + setWorktreesFolderDraft(worktreesFolderFromWorkspace ?? ""); setEnvironmentError(null); return; } - if (!environmentDirty && environmentSavedScript !== environmentSavedScriptFromWorkspace) { setEnvironmentSavedScript(environmentSavedScriptFromWorkspace); setEnvironmentDraftScript(environmentSavedScriptFromWorkspace ?? ""); setEnvironmentError(null); } + if (!worktreesFolderDirty && worktreesFolderSaved !== worktreesFolderFromWorkspace) { + setWorktreesFolderSaved(worktreesFolderFromWorkspace); + setWorktreesFolderDraft(worktreesFolderFromWorkspace ?? ""); + } }, [ environmentDirty, environmentLoadedWorkspaceId, environmentSavedScript, environmentSavedScriptFromWorkspace, environmentWorkspace, + worktreesFolderDirty, + worktreesFolderFromWorkspace, + worktreesFolderSaved, ]); const handleSaveEnvironmentSetup = async () => { - if (!environmentWorkspace || environmentSaving) { - return; - } + if (!environmentWorkspace || environmentSaving) return; const nextScript = environmentDraftNormalized; + const nextFolder = worktreesFolderDraft.trim() || null; setEnvironmentSaving(true); setEnvironmentError(null); try { await onUpdateWorkspaceSettings(environmentWorkspace.id, { worktreeSetupScript: nextScript, + worktreesFolder: nextFolder, }); setEnvironmentSavedScript(nextScript); setEnvironmentDraftScript(nextScript ?? ""); + setWorktreesFolderSaved(nextFolder); + setWorktreesFolderDraft(nextFolder ?? ""); } catch (error) { setEnvironmentError(error instanceof Error ? error.message : String(error)); } finally { @@ -134,8 +143,12 @@ export const useSettingsEnvironmentsSection = ({ environmentDraftScript, environmentSavedScript, environmentDirty, + worktreesFolderDraft, + worktreesFolderSaved, + worktreesFolderDirty, onSetEnvironmentWorkspaceId: setEnvironmentWorkspaceId, onSetEnvironmentDraftScript: setEnvironmentDraftScript, + onSetWorktreesFolderDraft: setWorktreesFolderDraft, onSaveEnvironmentSetup: handleSaveEnvironmentSetup, }; }; diff --git a/src/types.ts b/src/types.ts index aa68a1eb0..c980474e1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -7,6 +7,7 @@ export type WorkspaceSettings = { launchScript?: string | null; launchScripts?: LaunchScriptEntry[] | null; worktreeSetupScript?: string | null; + worktreesFolder?: string | null; }; export type LaunchScriptIconId = @@ -303,6 +304,7 @@ export type AppSettings = { composerListContinuation: boolean; composerCodeBlockCopyUseModifier: boolean; workspaceGroups: WorkspaceGroup[]; + globalWorktreesFolder: string | null; openAppTargets: OpenAppTarget[]; selectedOpenAppId: string; };