|
1 | 1 | use std::collections::HashMap; |
2 | 2 | use std::future::Future; |
3 | | -use std::path::PathBuf; |
| 3 | +use std::path::{Component, Path, PathBuf}; |
4 | 4 | use std::sync::Arc; |
5 | 5 |
|
6 | 6 | use tokio::sync::Mutex; |
@@ -222,6 +222,162 @@ where |
222 | 222 | }) |
223 | 223 | } |
224 | 224 |
|
| 225 | +fn default_repo_name_from_url(url: &str) -> Option<String> { |
| 226 | + let trimmed = url.trim().trim_end_matches('/'); |
| 227 | + let tail = trimmed.rsplit('/').next()?.trim(); |
| 228 | + if tail.is_empty() { |
| 229 | + return None; |
| 230 | + } |
| 231 | + let without_git_suffix = tail.strip_suffix(".git").unwrap_or(tail); |
| 232 | + if without_git_suffix.is_empty() { |
| 233 | + None |
| 234 | + } else { |
| 235 | + Some(without_git_suffix.to_string()) |
| 236 | + } |
| 237 | +} |
| 238 | + |
| 239 | +fn validate_target_folder_name(value: &str) -> Result<String, String> { |
| 240 | + let trimmed = value.trim(); |
| 241 | + if trimmed.is_empty() { |
| 242 | + return Err("Target folder name is required.".to_string()); |
| 243 | + } |
| 244 | + |
| 245 | + if trimmed.contains('/') || trimmed.contains('\\') { |
| 246 | + return Err( |
| 247 | + "Target folder name must be a single relative folder name without separators or traversal." |
| 248 | + .to_string(), |
| 249 | + ); |
| 250 | + } |
| 251 | + |
| 252 | + let path = Path::new(trimmed); |
| 253 | + match (path.components().next(), path.components().nth(1)) { |
| 254 | + (Some(Component::Normal(_)), None) => Ok(trimmed.to_string()), |
| 255 | + _ => Err( |
| 256 | + "Target folder name must be a single relative folder name without separators or traversal." |
| 257 | + .to_string(), |
| 258 | + ), |
| 259 | + } |
| 260 | +} |
| 261 | + |
| 262 | +pub(crate) async fn add_workspace_from_git_url_core<F, Fut>( |
| 263 | + url: String, |
| 264 | + destination_path: String, |
| 265 | + target_folder_name: Option<String>, |
| 266 | + codex_bin: Option<String>, |
| 267 | + workspaces: &Mutex<HashMap<String, WorkspaceEntry>>, |
| 268 | + sessions: &Mutex<HashMap<String, Arc<WorkspaceSession>>>, |
| 269 | + app_settings: &Mutex<AppSettings>, |
| 270 | + storage_path: &PathBuf, |
| 271 | + spawn_session: F, |
| 272 | +) -> Result<WorkspaceInfo, String> |
| 273 | +where |
| 274 | + F: Fn(WorkspaceEntry, Option<String>, Option<String>, Option<PathBuf>) -> Fut, |
| 275 | + Fut: Future<Output = Result<Arc<WorkspaceSession>, String>>, |
| 276 | +{ |
| 277 | + let url = url.trim().to_string(); |
| 278 | + if url.is_empty() { |
| 279 | + return Err("Remote Git URL is required.".to_string()); |
| 280 | + } |
| 281 | + let destination_path = destination_path.trim().to_string(); |
| 282 | + if destination_path.is_empty() { |
| 283 | + return Err("Destination folder is required.".to_string()); |
| 284 | + } |
| 285 | + let destination_parent = PathBuf::from(&destination_path); |
| 286 | + if !destination_parent.is_dir() { |
| 287 | + return Err("Destination folder must be an existing directory.".to_string()); |
| 288 | + } |
| 289 | + |
| 290 | + let folder_name = target_folder_name |
| 291 | + .as_deref() |
| 292 | + .map(str::trim) |
| 293 | + .filter(|value| !value.is_empty()) |
| 294 | + .map(str::to_string) |
| 295 | + .or_else(|| default_repo_name_from_url(&url)) |
| 296 | + .ok_or_else(|| "Could not determine target folder name from URL.".to_string())?; |
| 297 | + let folder_name = validate_target_folder_name(&folder_name)?; |
| 298 | + |
| 299 | + let clone_path = destination_parent.join(folder_name); |
| 300 | + if clone_path.exists() { |
| 301 | + let is_empty = std::fs::read_dir(&clone_path) |
| 302 | + .map_err(|err| format!("Failed to inspect destination path: {err}"))? |
| 303 | + .next() |
| 304 | + .is_none(); |
| 305 | + if !is_empty { |
| 306 | + return Err("Destination path already exists and is not empty.".to_string()); |
| 307 | + } |
| 308 | + } |
| 309 | + |
| 310 | + let clone_path_string = clone_path.to_string_lossy().to_string(); |
| 311 | + if let Err(error) = |
| 312 | + git_core::run_git_command(&destination_parent, &["clone", &url, &clone_path_string]).await |
| 313 | + { |
| 314 | + let _ = tokio::fs::remove_dir_all(&clone_path).await; |
| 315 | + return Err(error); |
| 316 | + } |
| 317 | + |
| 318 | + let workspace_name = clone_path |
| 319 | + .file_name() |
| 320 | + .and_then(|s| s.to_str()) |
| 321 | + .unwrap_or("Workspace") |
| 322 | + .to_string(); |
| 323 | + let entry = WorkspaceEntry { |
| 324 | + id: Uuid::new_v4().to_string(), |
| 325 | + name: workspace_name, |
| 326 | + path: clone_path_string, |
| 327 | + codex_bin, |
| 328 | + kind: WorkspaceKind::Main, |
| 329 | + parent_id: None, |
| 330 | + worktree: None, |
| 331 | + settings: WorkspaceSettings::default(), |
| 332 | + }; |
| 333 | + |
| 334 | + let (default_bin, codex_args) = { |
| 335 | + let settings = app_settings.lock().await; |
| 336 | + ( |
| 337 | + settings.codex_bin.clone(), |
| 338 | + resolve_workspace_codex_args(&entry, None, Some(&settings)), |
| 339 | + ) |
| 340 | + }; |
| 341 | + let codex_home = resolve_workspace_codex_home(&entry, None); |
| 342 | + let session = match spawn_session(entry.clone(), default_bin, codex_args, codex_home).await { |
| 343 | + Ok(session) => session, |
| 344 | + Err(error) => { |
| 345 | + let _ = tokio::fs::remove_dir_all(&clone_path).await; |
| 346 | + return Err(error); |
| 347 | + } |
| 348 | + }; |
| 349 | + |
| 350 | + if let Err(error) = { |
| 351 | + let mut workspaces = workspaces.lock().await; |
| 352 | + workspaces.insert(entry.id.clone(), entry.clone()); |
| 353 | + let list: Vec<_> = workspaces.values().cloned().collect(); |
| 354 | + write_workspaces(storage_path, &list) |
| 355 | + } { |
| 356 | + { |
| 357 | + let mut workspaces = workspaces.lock().await; |
| 358 | + workspaces.remove(&entry.id); |
| 359 | + } |
| 360 | + let mut child = session.child.lock().await; |
| 361 | + kill_child_process_tree(&mut child).await; |
| 362 | + let _ = tokio::fs::remove_dir_all(&clone_path).await; |
| 363 | + return Err(error); |
| 364 | + } |
| 365 | + |
| 366 | + sessions.lock().await.insert(entry.id.clone(), session); |
| 367 | + |
| 368 | + Ok(WorkspaceInfo { |
| 369 | + id: entry.id, |
| 370 | + name: entry.name, |
| 371 | + path: entry.path, |
| 372 | + codex_bin: entry.codex_bin, |
| 373 | + connected: true, |
| 374 | + kind: entry.kind, |
| 375 | + parent_id: entry.parent_id, |
| 376 | + worktree: entry.worktree, |
| 377 | + settings: entry.settings, |
| 378 | + }) |
| 379 | +} |
| 380 | + |
225 | 381 | pub(crate) async fn remove_workspace_core<FRunGit, FutRunGit, FIsMissing, FRemoveDirAll>( |
226 | 382 | id: String, |
227 | 383 | workspaces: &Mutex<HashMap<String, WorkspaceEntry>>, |
@@ -550,3 +706,45 @@ pub(crate) async fn update_workspace_codex_bin_core( |
550 | 706 | settings: entry_snapshot.settings, |
551 | 707 | }) |
552 | 708 | } |
| 709 | + |
| 710 | +#[cfg(test)] |
| 711 | +mod tests { |
| 712 | + use super::{default_repo_name_from_url, validate_target_folder_name}; |
| 713 | + |
| 714 | + #[test] |
| 715 | + fn derives_repo_name_from_https_url() { |
| 716 | + assert_eq!( |
| 717 | + default_repo_name_from_url("https://github.com/org/repo.git"), |
| 718 | + Some("repo".to_string()) |
| 719 | + ); |
| 720 | + } |
| 721 | + |
| 722 | + #[test] |
| 723 | + fn derives_repo_name_from_ssh_url() { |
| 724 | + assert_eq!( |
| 725 | + default_repo_name_from_url("git@github.com:org/repo.git"), |
| 726 | + Some("repo".to_string()) |
| 727 | + ); |
| 728 | + } |
| 729 | + |
| 730 | + #[test] |
| 731 | + fn accepts_single_relative_target_folder_name() { |
| 732 | + assert_eq!( |
| 733 | + validate_target_folder_name("my-project"), |
| 734 | + Ok("my-project".to_string()) |
| 735 | + ); |
| 736 | + } |
| 737 | + |
| 738 | + #[test] |
| 739 | + fn rejects_target_folder_name_with_separators() { |
| 740 | + let err = |
| 741 | + validate_target_folder_name("nested/project").expect_err("name should be rejected"); |
| 742 | + assert!(err.contains("without separators")); |
| 743 | + } |
| 744 | + |
| 745 | + #[test] |
| 746 | + fn rejects_target_folder_name_with_traversal() { |
| 747 | + let err = validate_target_folder_name("../project").expect_err("name should be rejected"); |
| 748 | + assert!(err.contains("without separators or traversal")); |
| 749 | + } |
| 750 | +} |
0 commit comments