Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,12 @@ type SettingsEnvironmentsSectionProps = {
environmentDraftScript: string;
environmentSavedScript: string | null;
environmentDirty: boolean;
worktreesFolderDraft: string;
worktreesFolderSaved: string | null;
worktreesFolderDirty: boolean;
onSetEnvironmentWorkspaceId: Dispatch<SetStateAction<string | null>>;
onSetEnvironmentDraftScript: Dispatch<SetStateAction<string>>;
onSetWorktreesFolderDraft: Dispatch<SetStateAction<string>>;
onSaveEnvironmentSetup: () => Promise<void>;
};

Expand All @@ -24,14 +28,20 @@ export function SettingsEnvironmentsSection({
environmentDraftScript,
environmentSavedScript,
environmentDirty,
worktreesFolderDraft,
worktreesFolderSaved,
worktreesFolderDirty,
onSetEnvironmentWorkspaceId,
onSetEnvironmentDraftScript,
onSetWorktreesFolderDraft,
onSaveEnvironmentSetup,
}: SettingsEnvironmentsSectionProps) {
const hasAnyChanges = environmentDirty || worktreesFolderDirty;

return (
<SettingsSection
title="Environments"
subtitle="Configure per-project setup scripts that run after worktree creation."
subtitle="Configure per-project setup scripts and worktree locations."
>
{mainWorkspaces.length === 0 ? (
<div className="settings-empty">No projects yet.</div>
Expand Down Expand Up @@ -116,12 +126,57 @@ export function SettingsEnvironmentsSection({
onClick={() => {
void onSaveEnvironmentSetup();
}}
disabled={environmentSaving || !environmentDirty}
disabled={environmentSaving || !hasAnyChanges}
>
{environmentSaving ? "Saving..." : "Save"}
</button>
</div>
</div>

<div className="settings-field">
<label className="settings-field-label" htmlFor="settings-worktrees-folder">
Worktrees folder
</label>
<div className="settings-help">
Custom location for worktrees. Leave empty to use the default location.
</div>
<div className="settings-field-row">
<input
id="settings-worktrees-folder"
type="text"
className="settings-input"
value={worktreesFolderDraft}
onChange={(event) => onSetWorktreesFolderDraft(event.target.value)}
placeholder="/path/to/worktrees"
disabled={environmentSaving}
/>
<button
type="button"
className="ghost settings-button-compact"
onClick={async () => {
try {
const { open } = await import("@tauri-apps/plugin-dialog");
const selected = await open({
directory: true,
multiple: false,
title: "Select worktrees folder",
});
if (selected && typeof selected === "string") {
onSetWorktreesFolderDraft(selected);
}
} catch (error) {
pushErrorToast({
title: "Failed to open folder picker",
message: error instanceof Error ? error.message : String(error),
});
}
}}
disabled={environmentSaving}
>
Browse
</button>
</div>
</div>
</>
)}
</SettingsSection>
Expand Down
Loading