//! Parallel copy engine. use std::path::Path; use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; use anyhow::Result; use crossbeam::channel::{Sender, bounded}; use dashmap::{DashMap, DashSet}; use ignore::{WalkBuilder, WalkState}; use tokio_util::sync::CancellationToken; use crate::copy::shard::shard_for_path; use crate::copy::skip::build_skip_matcher; use crate::copy::types::{ CopyEntry, CopyEntryKind, CopyStats, ParallelCopyConfig, ParallelCopyResult, }; use crate::copy::worker::{WorkerCtx, run_worker}; /// Copy files from source to dest using parallel workers with hash-based sharding. /// /// Returns both stats and the set of paths that were copied (for deduplication). /// Maximum worker threads to prevent FD exhaustion on macOS. /// macOS default ulimit is 256. With 8 workers + 8 walker threads = 16 threads, /// each can have ~10 FDs open (deeply nested dirs), leaving headroom for other uses. #[cfg(target_os = "macos")] const MAX_PARALLEL_WORKERS: usize = 8; #[cfg(not(target_os = "macos"))] const MAX_PARALLEL_WORKERS: usize = 32; pub(crate) fn copy_parallel( source: &Path, dest: &Path, config: ParallelCopyConfig, cancellation_token: CancellationToken, ) -> Result { let num_workers = if config.num_workers == 0 { num_cpus::get().min(MAX_PARALLEL_WORKERS) } else { config.num_workers.min(MAX_PARALLEL_WORKERS) }; // Build skip patterns matcher. let skip_matcher = if !config.skip_patterns.is_empty() { Some(build_skip_matcher(&config.skip_patterns)?) } else { None }; // Create bounded channels for each shard. let channels: Vec<_> = (0..num_workers) .map(|_| bounded::(config.channel_buffer)) .collect(); // Shared atomic counters for stats. let files_copied = Arc::new(AtomicU64::new(0)); let dirs_created = Arc::new(AtomicU64::new(0)); let symlinks_copied = Arc::new(AtomicU64::new(0)); let files_skipped = Arc::new(AtomicU64::new(0)); let issues: Arc>> = Arc::new(std::sync::Mutex::new(Vec::new())); // Track successfully copied paths for deduplication. let copied_paths: Arc> = Arc::new(DashSet::new()); // Collect file metadata for index updates. let file_metadata: Arc> = Arc::new(DashMap::new()); // Spawn worker threads. let workers: Vec<_> = channels .iter() .map(|(_, rx)| { let rx = rx.clone(); let ctx = WorkerCtx { source: source.to_path_buf(), dest: dest.to_path_buf(), files_copied: Arc::clone(&files_copied), dirs_created: Arc::clone(&dirs_created), symlinks_copied: Arc::clone(&symlinks_copied), issues: Arc::clone(&issues), copied_paths: Arc::clone(&copied_paths), file_metadata: Arc::clone(&file_metadata), }; std::thread::spawn(move || run_worker(rx, ctx)) }) .collect(); // Collect senders for the walker. let senders: Vec> = channels.iter().map(|(tx, _)| tx.clone()).collect(); // Build the walker. // IMPORTANT: Limit walker threads to match num_workers to avoid FD exhaustion. // On macOS, the default FD limit (256) can easily be exceeded when: // - num_cpus walker threads (default) × directories open per thread // - Plus num_workers copy workers × files being copied // Deep directory trees (15+ levels) amplify this significantly. let mut builder = WalkBuilder::new(source); builder .hidden(false) // Include hidden files. .git_ignore(config.respect_gitignore) .git_global(false) // Never use global gitignore (~/.config/git/ignore) — // it contains personal preferences irrelevant to worktree creation. .git_exclude(false) // Never use .git/info/exclude — external tooling // can append broad patterns (*.min.js, *.zip) that // incorrectly skip git-tracked files. The `ignore` crate doesn't // check tracking status, so tracked files matching these patterns // get silently dropped during the copy. .threads(num_workers) // Limit walker parallelism to avoid FD exhaustion .filter_entry(|entry| { // Always skip .git directory. entry.file_name() != ".git" }); let walker = builder.build_parallel(); // Clone data for the walker closure. let source_for_walker = source.to_path_buf(); let skip_files = config.skip_files.clone(); let files_skipped_walker = Arc::clone(&files_skipped); let skip_matcher = skip_matcher.map(Arc::new); // Run the parallel walker. walker.run(|| { let senders = senders.clone(); let source = source_for_walker.clone(); let n = num_workers; let skip_files = skip_files.clone(); let files_skipped = Arc::clone(&files_skipped_walker); let skip_matcher = skip_matcher.clone(); let cancellation_token = cancellation_token.clone(); Box::new(move |entry_result| { // Check for cancellation if cancellation_token.is_cancelled() { return WalkState::Quit; } let entry = match entry_result { Ok(e) => e, Err(_) => return WalkState::Continue, }; // Get relative path. let rel_path = match entry.path().strip_prefix(&source) { Ok(p) => p.to_path_buf(), Err(_) => return WalkState::Continue, }; // Skip root. if rel_path.as_os_str().is_empty() { return WalkState::Continue; } // Check if this file should be skipped (already copied or explicitly skipped). if let Some(ref skip) = skip_files && skip.contains(&rel_path) { files_skipped.fetch_add(1, Ordering::Relaxed); return WalkState::Continue; } // Check skip patterns. if let Some(ref matcher) = skip_matcher && matcher.is_match(&rel_path) { files_skipped.fetch_add(1, Ordering::Relaxed); return WalkState::Continue; } let file_type = entry.file_type(); let is_dir = file_type.as_ref().map(|ft| ft.is_dir()).unwrap_or(false); let is_symlink = file_type .as_ref() .map(|ft| ft.is_symlink()) .unwrap_or(false); let kind = if is_dir { CopyEntryKind::Dir } else if is_symlink { CopyEntryKind::Symlink } else { CopyEntryKind::File }; // Compute shard and send. let shard = shard_for_path(&rel_path, n); let _ = senders[shard].send(CopyEntry { rel_path, kind }); WalkState::Continue }) }); // Close senders to signal workers to finish. drop(senders); for (tx, _) in channels { drop(tx); } // Wait for all workers. for worker in workers { let _ = worker.join(); } // Collect issues. let issues = match Arc::try_unwrap(issues) { Ok(mutex) => mutex.into_inner().unwrap_or_default(), Err(arc) => arc.lock().unwrap().clone(), }; let copied_paths = match Arc::try_unwrap(copied_paths) { Ok(set) => set, Err(arc) => { let mut set = DashSet::new(); set.extend(arc.iter().map(|p| p.clone())); set } }; let file_metadata = match Arc::try_unwrap(file_metadata) { Ok(map) => map, Err(arc) => { let map = DashMap::new(); for entry in arc.iter() { map.insert(entry.key().clone(), entry.value().clone()); } map } }; Ok(ParallelCopyResult { stats: CopyStats { files_copied: files_copied.load(Ordering::Relaxed), dirs_created: dirs_created.load(Ordering::Relaxed), symlinks_copied: symlinks_copied.load(Ordering::Relaxed), files_skipped: files_skipped.load(Ordering::Relaxed), issues, }, copied_paths, file_metadata, }) } #[cfg(test)] mod tests { use super::*; use crate::copy::types::ParallelCopyConfig; use std::path::PathBuf; use tempfile::TempDir; #[test] fn test_copy_parallel_simple() { let src = TempDir::new().unwrap(); let dest = TempDir::new().unwrap(); // Create some files std::fs::write(src.path().join("file1.txt"), "content1").unwrap(); std::fs::write(src.path().join("file2.txt"), "content2").unwrap(); std::fs::create_dir(src.path().join("subdir")).unwrap(); std::fs::write(src.path().join("subdir/file3.txt"), "content3").unwrap(); let config = ParallelCopyConfig { num_workers: 2, channel_buffer: 64, respect_gitignore: false, ..Default::default() }; let result = copy_parallel(src.path(), dest.path(), config, CancellationToken::new()).unwrap(); assert_eq!(result.stats.files_copied, 3); assert!(dest.path().join("file1.txt").exists()); assert!(dest.path().join("file2.txt").exists()); assert!(dest.path().join("subdir/file3.txt").exists()); assert_eq!(result.copied_paths.len(), 4); // 3 files + 1 dir } #[test] fn test_copy_parallel_with_skip() { let src = TempDir::new().unwrap(); let dest = TempDir::new().unwrap(); std::fs::write(src.path().join("keep.txt"), "keep").unwrap(); std::fs::write(src.path().join("skip.txt"), "skip").unwrap(); let skip = DashSet::new(); skip.insert(PathBuf::from("skip.txt")); let config = ParallelCopyConfig { num_workers: 2, channel_buffer: 64, skip_files: Some(Arc::new(skip)), respect_gitignore: false, ..Default::default() }; let result = copy_parallel(src.path(), dest.path(), config, CancellationToken::new()).unwrap(); assert_eq!(result.stats.files_copied, 1); assert_eq!(result.stats.files_skipped, 1); assert!(dest.path().join("keep.txt").exists()); assert!(!dest.path().join("skip.txt").exists()); } #[test] fn test_copy_parallel_only_ignored() { kigi_test_utils::require_git!(); let src = TempDir::new().unwrap(); let dest = TempDir::new().unwrap(); // Create a tracked file std::fs::write(src.path().join("tracked.txt"), "tracked").unwrap(); // Create an "ignored" directory std::fs::create_dir(src.path().join("node_modules")).unwrap(); std::fs::write(src.path().join("node_modules/pkg.txt"), "pkg").unwrap(); // Create .gitignore std::fs::write(src.path().join(".gitignore"), "node_modules/").unwrap(); // Initialize git repo std::process::Command::new("git") .current_dir(src.path()) .args(["init"]) .output() .unwrap(); // Copy only ignored files (skip unignored paths) let config = ParallelCopyConfig { num_workers: 2, channel_buffer: 64, skip_files: Some(Arc::new( crate::copy::collect_unignored_paths(src.path(), 1).unwrap(), )), ..Default::default() }; let _result = copy_parallel(src.path(), dest.path(), config, CancellationToken::new()).unwrap(); // Should have copied node_modules but not tracked.txt assert!(dest.path().join("node_modules/pkg.txt").exists()); assert!(!dest.path().join("tracked.txt").exists()); } #[test] fn test_copy_parallel_with_cancellation_token_cancelled() { use tokio_util::sync::CancellationToken; let src = TempDir::new().unwrap(); let dest = TempDir::new().unwrap(); // Create some files std::fs::write(src.path().join("file1.txt"), "content1").unwrap(); std::fs::write(src.path().join("file2.txt"), "content2").unwrap(); std::fs::write(src.path().join("file3.txt"), "content3").unwrap(); // Create cancellation token - cancel immediately (pre-cancelled) let token = CancellationToken::new(); token.cancel(); let config = ParallelCopyConfig { num_workers: 2, channel_buffer: 64, respect_gitignore: false, ..Default::default() }; // Pass the PRE-CANCELLED token (not a fresh one): the walker checks // cancellation first thing in every callback and quits, so nothing is // ever queued to the workers. let result = copy_parallel(src.path(), dest.path(), config, token).unwrap(); assert_eq!( result.stats.files_copied, 0, "a pre-cancelled token must short-circuit the copy before any file is written" ); } #[test] fn test_copy_parallel_cancellation_token_not_cancelled() { use tokio_util::sync::CancellationToken; let src = TempDir::new().unwrap(); let dest = TempDir::new().unwrap(); // Create some files std::fs::write(src.path().join("file1.txt"), "content1").unwrap(); std::fs::write(src.path().join("file2.txt"), "content2").unwrap(); let config = ParallelCopyConfig { num_workers: 2, channel_buffer: 64, respect_gitignore: false, ..Default::default() }; let result = copy_parallel(src.path(), dest.path(), config, CancellationToken::new()).unwrap(); // All files should be copied assert_eq!(result.stats.files_copied, 2); assert!(dest.path().join("file1.txt").exists()); assert!(dest.path().join("file2.txt").exists()); } #[cfg(unix)] #[test] fn test_copy_parallel_replicates_symlink() { // Exercises the worker's CopyEntryKind::Symlink arm: a symlink in the // source tree must be replicated AS a symlink (not dereferenced). let src = TempDir::new().unwrap(); let dest = TempDir::new().unwrap(); std::fs::write(src.path().join("target.txt"), "content").unwrap(); std::os::unix::fs::symlink("target.txt", src.path().join("link.txt")).unwrap(); let config = ParallelCopyConfig { num_workers: 2, channel_buffer: 64, respect_gitignore: false, ..Default::default() }; let result = copy_parallel(src.path(), dest.path(), config, CancellationToken::new()).unwrap(); assert_eq!(result.stats.symlinks_copied, 1); let meta = std::fs::symlink_metadata(dest.path().join("link.txt")).unwrap(); assert!( meta.file_type().is_symlink(), "link must be replicated as a symlink" ); assert_eq!( std::fs::read_link(dest.path().join("link.txt")).unwrap(), PathBuf::from("target.txt") ); } }