From 5aea646a8a5ba36e095260f7868f504d974409c4 Mon Sep 17 00:00:00 2001 From: bilalbayram Date: Thu, 12 Feb 2026 16:13:50 +0300 Subject: [PATCH] fix(workspaces): allow removing projects after repo folder is deleted When a main workspace directory is deleted outside CodexMonitor, removal still attempted `git worktree remove` using the missing parent repo as current_dir. That produced `Failed to run git: No such file or directory (os error 2)`, caused child failures to accumulate, and blocked parent workspace removal. This patch updates shared workspaces core removal paths to treat missing parent repo folders as a filesystem-only cleanup flow: - In `remove_workspace_core`, skip git worktree remove/prune when parent path is not an existing directory, and remove child worktree folders directly. - In `remove_worktree_core`, skip git worktree remove/prune when parent path is missing and remove the worktree folder directly. - Added regression tests covering both parent-project removal and single-worktree removal when the parent repo folder no longer exists; tests assert the operation succeeds and git is not invoked. Validation run: - cargo test --manifest-path src-tauri/Cargo.toml remove_workspace_succeeds_when_parent_repo_folder_is_missing - cargo test --manifest-path src-tauri/Cargo.toml remove_worktree_succeeds_when_parent_repo_folder_is_missing - cargo check --manifest-path src-tauri/Cargo.toml - npm run typecheck --- .../workspaces_core/crud_persistence.rs | 15 ++- .../src/shared/workspaces_core/worktree.rs | 9 +- src-tauri/src/workspaces/tests.rs | 121 +++++++++++++++++- 3 files changed, 140 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/shared/workspaces_core/crud_persistence.rs b/src-tauri/src/shared/workspaces_core/crud_persistence.rs index 19a868b64..5e974d71c 100644 --- a/src-tauri/src/shared/workspaces_core/crud_persistence.rs +++ b/src-tauri/src/shared/workspaces_core/crud_persistence.rs @@ -257,6 +257,7 @@ where }; let repo_path = PathBuf::from(&entry.path); + let repo_path_exists = repo_path.is_dir(); let mut removed_child_ids = Vec::new(); let mut failures: Vec<(String, String)> = Vec::new(); @@ -265,7 +266,15 @@ where let child_path = PathBuf::from(&child.path); if child_path.exists() { - if let Err(error) = + if !repo_path_exists { + if let Err(fs_error) = remove_dir_all(&child_path) { + if continue_on_child_error { + failures.push((child.id.clone(), fs_error)); + continue; + } + return Err(fs_error); + } + } else if let Err(error) = run_git_command(&repo_path, &["worktree", "remove", "--force", &child.path]).await { if is_missing_worktree_error(&error) { @@ -290,7 +299,9 @@ where removed_child_ids.push(child.id.clone()); } - let _ = run_git_command(&repo_path, &["worktree", "prune", "--expire", "now"]).await; + if repo_path_exists { + let _ = run_git_command(&repo_path, &["worktree", "prune", "--expire", "now"]).await; + } let mut ids_to_remove = removed_child_ids; if failures.is_empty() || !require_all_children_removed_to_remove_parent { diff --git a/src-tauri/src/shared/workspaces_core/worktree.rs b/src-tauri/src/shared/workspaces_core/worktree.rs index c954abb10..c420294b8 100644 --- a/src-tauri/src/shared/workspaces_core/worktree.rs +++ b/src-tauri/src/shared/workspaces_core/worktree.rs @@ -274,11 +274,14 @@ where }; let parent_path = PathBuf::from(&parent.path); + let parent_path_exists = parent_path.is_dir(); let entry_path = PathBuf::from(&entry.path); kill_session_by_id(sessions, &entry.id).await; if entry_path.exists() { - if let Err(error) = run_git_command( + if !parent_path_exists { + remove_dir_all(&entry_path)?; + } else if let Err(error) = run_git_command( &parent_path, &["worktree", "remove", "--force", &entry.path], ) @@ -293,7 +296,9 @@ where } } } - let _ = run_git_command(&parent_path, &["worktree", "prune", "--expire", "now"]).await; + if parent_path_exists { + let _ = run_git_command(&parent_path, &["worktree", "prune", "--expire", "now"]).await; + } { let mut workspaces = workspaces.lock().await; diff --git a/src-tauri/src/workspaces/tests.rs b/src-tauri/src/workspaces/tests.rs index fffd2d110..d964b53b6 100644 --- a/src-tauri/src/workspaces/tests.rs +++ b/src-tauri/src/workspaces/tests.rs @@ -8,7 +8,9 @@ use super::worktree::{ build_clone_destination_path, sanitize_clone_dir_name, sanitize_worktree_name, }; use crate::backend::app_server::WorkspaceSession; -use crate::shared::workspaces_core::rename_worktree_core; +use crate::shared::workspaces_core::{ + remove_workspace_core, remove_worktree_core, rename_worktree_core, +}; use crate::storage::{read_workspaces, write_workspaces}; use crate::types::{ AppSettings, WorkspaceEntry, WorkspaceInfo, WorkspaceKind, WorkspaceSettings, WorktreeInfo, @@ -394,3 +396,120 @@ fn rename_worktree_updates_name_when_unmodified() { assert_eq!(updated.name, "feature/new"); }); } + +#[test] +fn remove_workspace_succeeds_when_parent_repo_folder_is_missing() { + run_async(async { + let temp_dir = std::env::temp_dir().join(format!("codex-monitor-test-{}", Uuid::new_v4())); + let parent_repo_path = temp_dir.join("deleted-parent-repo"); + let child_path = temp_dir.join("worktrees").join("parent").join("feature-a"); + std::fs::create_dir_all(&child_path).expect("create child path"); + + let parent = WorkspaceEntry { + id: "parent".to_string(), + name: "Parent".to_string(), + path: parent_repo_path.to_string_lossy().to_string(), + codex_bin: None, + kind: WorkspaceKind::Main, + parent_id: None, + worktree: None, + settings: WorkspaceSettings::default(), + }; + let child = WorkspaceEntry { + id: "wt-missing-parent".to_string(), + name: "feature-a".to_string(), + path: child_path.to_string_lossy().to_string(), + codex_bin: None, + kind: WorkspaceKind::Worktree, + parent_id: Some(parent.id.clone()), + worktree: Some(WorktreeInfo { + branch: "feature-a".to_string(), + }), + settings: WorkspaceSettings::default(), + }; + let workspaces = Mutex::new(HashMap::from([ + (parent.id.clone(), parent.clone()), + (child.id.clone(), child.clone()), + ])); + let sessions: Mutex>> = Mutex::new(HashMap::new()); + let storage_path = temp_dir.join("workspaces.json"); + + remove_workspace_core( + parent.id.clone(), + &workspaces, + &sessions, + &storage_path, + |_root, _args| async move { + panic!("git should not run when parent repo folder is missing"); + }, + |_error| false, + |path| std::fs::remove_dir_all(path).map_err(|err| err.to_string()), + true, + true, + ) + .await + .expect("remove workspace"); + + assert!(!child_path.exists()); + let workspaces_guard = workspaces.lock().await; + assert!(workspaces_guard.is_empty()); + }); +} + +#[test] +fn remove_worktree_succeeds_when_parent_repo_folder_is_missing() { + run_async(async { + let temp_dir = std::env::temp_dir().join(format!("codex-monitor-test-{}", Uuid::new_v4())); + let parent_repo_path = temp_dir.join("deleted-parent-repo"); + let child_path = temp_dir.join("worktrees").join("parent").join("feature-b"); + std::fs::create_dir_all(&child_path).expect("create child path"); + + let parent = WorkspaceEntry { + id: "parent".to_string(), + name: "Parent".to_string(), + path: parent_repo_path.to_string_lossy().to_string(), + codex_bin: None, + kind: WorkspaceKind::Main, + parent_id: None, + worktree: None, + settings: WorkspaceSettings::default(), + }; + let child = WorkspaceEntry { + id: "wt-remove-only".to_string(), + name: "feature-b".to_string(), + path: child_path.to_string_lossy().to_string(), + codex_bin: None, + kind: WorkspaceKind::Worktree, + parent_id: Some(parent.id.clone()), + worktree: Some(WorktreeInfo { + branch: "feature-b".to_string(), + }), + settings: WorkspaceSettings::default(), + }; + let workspaces = Mutex::new(HashMap::from([ + (parent.id.clone(), parent.clone()), + (child.id.clone(), child.clone()), + ])); + let sessions: Mutex>> = Mutex::new(HashMap::new()); + let storage_path = temp_dir.join("workspaces.json"); + + remove_worktree_core( + child.id.clone(), + &workspaces, + &sessions, + &storage_path, + |_root, _args| async move { + panic!("git should not run when parent repo folder is missing"); + }, + |_error| false, + |path| std::fs::remove_dir_all(path).map_err(|err| err.to_string()), + ) + .await + .expect("remove worktree"); + + assert!(!child_path.exists()); + let workspaces_guard = workspaces.lock().await; + assert!(workspaces_guard.contains_key(&parent.id)); + assert!(!workspaces_guard.contains_key(&child.id)); + }); +}