Skip to content

Commit 267d0da

Browse files
fix(workspaces): allow removing projects when repo folder was deleted on disk (#421)
Co-authored-by: Thomas Ricouard <ricouard77@gmail.com>
1 parent b596fef commit 267d0da

3 files changed

Lines changed: 140 additions & 5 deletions

File tree

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

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,7 @@ where
257257
};
258258

259259
let repo_path = PathBuf::from(&entry.path);
260+
let repo_path_exists = repo_path.is_dir();
260261
let mut removed_child_ids = Vec::new();
261262
let mut failures: Vec<(String, String)> = Vec::new();
262263

@@ -265,7 +266,15 @@ where
265266

266267
let child_path = PathBuf::from(&child.path);
267268
if child_path.exists() {
268-
if let Err(error) =
269+
if !repo_path_exists {
270+
if let Err(fs_error) = remove_dir_all(&child_path) {
271+
if continue_on_child_error {
272+
failures.push((child.id.clone(), fs_error));
273+
continue;
274+
}
275+
return Err(fs_error);
276+
}
277+
} else if let Err(error) =
269278
run_git_command(&repo_path, &["worktree", "remove", "--force", &child.path]).await
270279
{
271280
if is_missing_worktree_error(&error) {
@@ -290,7 +299,9 @@ where
290299
removed_child_ids.push(child.id.clone());
291300
}
292301

293-
let _ = run_git_command(&repo_path, &["worktree", "prune", "--expire", "now"]).await;
302+
if repo_path_exists {
303+
let _ = run_git_command(&repo_path, &["worktree", "prune", "--expire", "now"]).await;
304+
}
294305

295306
let mut ids_to_remove = removed_child_ids;
296307
if failures.is_empty() || !require_all_children_removed_to_remove_parent {

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

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -274,11 +274,14 @@ where
274274
};
275275

276276
let parent_path = PathBuf::from(&parent.path);
277+
let parent_path_exists = parent_path.is_dir();
277278
let entry_path = PathBuf::from(&entry.path);
278279
kill_session_by_id(sessions, &entry.id).await;
279280

280281
if entry_path.exists() {
281-
if let Err(error) = run_git_command(
282+
if !parent_path_exists {
283+
remove_dir_all(&entry_path)?;
284+
} else if let Err(error) = run_git_command(
282285
&parent_path,
283286
&["worktree", "remove", "--force", &entry.path],
284287
)
@@ -293,7 +296,9 @@ where
293296
}
294297
}
295298
}
296-
let _ = run_git_command(&parent_path, &["worktree", "prune", "--expire", "now"]).await;
299+
if parent_path_exists {
300+
let _ = run_git_command(&parent_path, &["worktree", "prune", "--expire", "now"]).await;
301+
}
297302

298303
{
299304
let mut workspaces = workspaces.lock().await;

src-tauri/src/workspaces/tests.rs

Lines changed: 120 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ use super::worktree::{
88
build_clone_destination_path, sanitize_clone_dir_name, sanitize_worktree_name,
99
};
1010
use crate::backend::app_server::WorkspaceSession;
11-
use crate::shared::workspaces_core::rename_worktree_core;
11+
use crate::shared::workspaces_core::{
12+
remove_workspace_core, remove_worktree_core, rename_worktree_core,
13+
};
1214
use crate::storage::{read_workspaces, write_workspaces};
1315
use crate::types::{
1416
AppSettings, WorkspaceEntry, WorkspaceInfo, WorkspaceKind, WorkspaceSettings, WorktreeInfo,
@@ -394,3 +396,120 @@ fn rename_worktree_updates_name_when_unmodified() {
394396
assert_eq!(updated.name, "feature/new");
395397
});
396398
}
399+
400+
#[test]
401+
fn remove_workspace_succeeds_when_parent_repo_folder_is_missing() {
402+
run_async(async {
403+
let temp_dir = std::env::temp_dir().join(format!("codex-monitor-test-{}", Uuid::new_v4()));
404+
let parent_repo_path = temp_dir.join("deleted-parent-repo");
405+
let child_path = temp_dir.join("worktrees").join("parent").join("feature-a");
406+
std::fs::create_dir_all(&child_path).expect("create child path");
407+
408+
let parent = WorkspaceEntry {
409+
id: "parent".to_string(),
410+
name: "Parent".to_string(),
411+
path: parent_repo_path.to_string_lossy().to_string(),
412+
codex_bin: None,
413+
kind: WorkspaceKind::Main,
414+
parent_id: None,
415+
worktree: None,
416+
settings: WorkspaceSettings::default(),
417+
};
418+
let child = WorkspaceEntry {
419+
id: "wt-missing-parent".to_string(),
420+
name: "feature-a".to_string(),
421+
path: child_path.to_string_lossy().to_string(),
422+
codex_bin: None,
423+
kind: WorkspaceKind::Worktree,
424+
parent_id: Some(parent.id.clone()),
425+
worktree: Some(WorktreeInfo {
426+
branch: "feature-a".to_string(),
427+
}),
428+
settings: WorkspaceSettings::default(),
429+
};
430+
let workspaces = Mutex::new(HashMap::from([
431+
(parent.id.clone(), parent.clone()),
432+
(child.id.clone(), child.clone()),
433+
]));
434+
let sessions: Mutex<HashMap<String, Arc<WorkspaceSession>>> = Mutex::new(HashMap::new());
435+
let storage_path = temp_dir.join("workspaces.json");
436+
437+
remove_workspace_core(
438+
parent.id.clone(),
439+
&workspaces,
440+
&sessions,
441+
&storage_path,
442+
|_root, _args| async move {
443+
panic!("git should not run when parent repo folder is missing");
444+
},
445+
|_error| false,
446+
|path| std::fs::remove_dir_all(path).map_err(|err| err.to_string()),
447+
true,
448+
true,
449+
)
450+
.await
451+
.expect("remove workspace");
452+
453+
assert!(!child_path.exists());
454+
let workspaces_guard = workspaces.lock().await;
455+
assert!(workspaces_guard.is_empty());
456+
});
457+
}
458+
459+
#[test]
460+
fn remove_worktree_succeeds_when_parent_repo_folder_is_missing() {
461+
run_async(async {
462+
let temp_dir = std::env::temp_dir().join(format!("codex-monitor-test-{}", Uuid::new_v4()));
463+
let parent_repo_path = temp_dir.join("deleted-parent-repo");
464+
let child_path = temp_dir.join("worktrees").join("parent").join("feature-b");
465+
std::fs::create_dir_all(&child_path).expect("create child path");
466+
467+
let parent = WorkspaceEntry {
468+
id: "parent".to_string(),
469+
name: "Parent".to_string(),
470+
path: parent_repo_path.to_string_lossy().to_string(),
471+
codex_bin: None,
472+
kind: WorkspaceKind::Main,
473+
parent_id: None,
474+
worktree: None,
475+
settings: WorkspaceSettings::default(),
476+
};
477+
let child = WorkspaceEntry {
478+
id: "wt-remove-only".to_string(),
479+
name: "feature-b".to_string(),
480+
path: child_path.to_string_lossy().to_string(),
481+
codex_bin: None,
482+
kind: WorkspaceKind::Worktree,
483+
parent_id: Some(parent.id.clone()),
484+
worktree: Some(WorktreeInfo {
485+
branch: "feature-b".to_string(),
486+
}),
487+
settings: WorkspaceSettings::default(),
488+
};
489+
let workspaces = Mutex::new(HashMap::from([
490+
(parent.id.clone(), parent.clone()),
491+
(child.id.clone(), child.clone()),
492+
]));
493+
let sessions: Mutex<HashMap<String, Arc<WorkspaceSession>>> = Mutex::new(HashMap::new());
494+
let storage_path = temp_dir.join("workspaces.json");
495+
496+
remove_worktree_core(
497+
child.id.clone(),
498+
&workspaces,
499+
&sessions,
500+
&storage_path,
501+
|_root, _args| async move {
502+
panic!("git should not run when parent repo folder is missing");
503+
},
504+
|_error| false,
505+
|path| std::fs::remove_dir_all(path).map_err(|err| err.to_string()),
506+
)
507+
.await
508+
.expect("remove worktree");
509+
510+
assert!(!child_path.exists());
511+
let workspaces_guard = workspaces.lock().await;
512+
assert!(workspaces_guard.contains_key(&parent.id));
513+
assert!(!workspaces_guard.contains_key(&child.id));
514+
});
515+
}

0 commit comments

Comments
 (0)