Skip to main content

workon/
copy_untracked.rs

1//! Enhanced file copying with pattern matching and platform optimizations.
2//!
3//! This module provides pattern-based file copying between worktrees with platform-specific
4//! optimizations for efficient copying of large files and directories.
5//!
6//! ## Design
7//!
8//! - Uses `ignore::WalkBuilder` + git index check to enumerate candidate files.
9//! - The walker respects `.gitignore` by default (never enters `node_modules/`, `target/`, etc.).
10//! - With `include_ignored`, gitignore filtering is disabled so ignored files are visited too.
11//! - The git index is checked per file (O(1) binary search) to skip tracked files.
12//! - Patterns filter the candidate list.
13//! - Opt-in ignored file support: `--include-ignored` / `workon.copyIncludeIgnored=true`.
14//!
15//! ## Pattern Matching
16//!
17//! Uses standard glob patterns via the `glob` crate:
18//! - `*.env` - All .env files in current directory
19//! - `.env*` - All files starting with .env
20//! - `**/*.json` - All JSON files recursively
21//! - `.vscode/` - Entire directory and contents
22//!
23//! Exclude patterns work the same way, checked after include patterns match.
24//! An empty include pattern list means "match all candidates".
25//!
26//! ## Platform Optimizations
27//!
28//! Platform-specific copy-on-write optimizations for large files:
29//! - **macOS**: `clonefile(2)` syscall — instant CoW copies on APFS
30//! - **Linux**: `ioctl(FICLONE)` — CoW copies on btrfs/XFS when supported
31//! - **Other**: Standard `fs::copy` fallback
32//!
33//! ## Behavior
34//!
35//! - Only copies files (directories are skipped, but created as needed for nested files)
36//! - Automatic parent directory creation for nested files
37//! - Skips files that already exist at destination (unless --force)
38//! - Returns list of successfully copied files
39//!
40//! ## Example Usage
41//!
42//! ```bash
43//! # Copy specific patterns
44//! git workon copy-untracked --pattern '.env*' --pattern '.vscode/'
45//!
46//! # Configure automatic copying with ignored files
47//! git config workon.autoCopyUntracked true
48//! git config workon.copyIncludeIgnored true
49//! git config --add workon.copyPattern '.env.local'
50//! git config --add workon.copyPattern 'node_modules/'
51//! git config --add workon.copyExclude '.env.production'
52//! ```
53
54use std::fs;
55use std::path::{Path, PathBuf};
56
57use crate::error::{CopyError, Result};
58
59type SkipCallback = Box<dyn FnMut(&'static str, &Path)>;
60
61/// Options for [`copy_untracked`].
62///
63/// Callbacks default to no-ops; override them to observe progress.
64pub struct CopyOptions<'a> {
65    /// Glob patterns to include; empty means match all candidates.
66    pub patterns: &'a [String],
67    /// Glob patterns to exclude after include matching.
68    pub excludes: &'a [String],
69    /// Overwrite files that already exist at the destination.
70    pub force: bool,
71    /// Also copy git-ignored files (e.g., `node_modules/`, `.env.local`).
72    pub include_ignored: bool,
73    /// Called after each file is successfully copied.
74    pub on_copied: Box<dyn FnMut(&Path)>,
75    /// Called when a file is skipped, with a reason of `"tracked"` or `"exists"`.
76    pub on_skipped: SkipCallback,
77}
78
79impl Default for CopyOptions<'_> {
80    fn default() -> Self {
81        Self {
82            patterns: &[],
83            excludes: &[],
84            force: false,
85            include_ignored: false,
86            on_copied: Box::new(|_| {}),
87            on_skipped: Box::new(|_, _| {}),
88        }
89    }
90}
91
92/// Copy only untracked (and optionally ignored) files from source to destination.
93///
94/// Uses `ignore::WalkBuilder` to walk `from_path`, skipping gitignored paths by default
95/// (so `node_modules/`, `target/`, etc. are never entered). With `include_ignored`, gitignore
96/// filtering is disabled and all files are visited. In both cases, tracked files are filtered
97/// out via an O(1) git index lookup.
98pub fn copy_untracked(
99    from_path: &Path,
100    to_path: &Path,
101    options: CopyOptions<'_>,
102) -> Result<Vec<PathBuf>> {
103    let CopyOptions {
104        patterns,
105        excludes,
106        force,
107        include_ignored,
108        mut on_copied,
109        mut on_skipped,
110    } = options;
111
112    let repo = git2::Repository::open(from_path).map_err(|source| CopyError::RepoOpen {
113        path: from_path.to_path_buf(),
114        source,
115    })?;
116
117    // Load git index once for O(1) tracked-file checks per file
118    let mut index = repo.index().map_err(|source| CopyError::RepoOpen {
119        path: from_path.to_path_buf(),
120        source,
121    })?;
122    index.read(false).map_err(|source| CopyError::RepoOpen {
123        path: from_path.to_path_buf(),
124        source,
125    })?;
126
127    // Compile include patterns once. Empty list = match all.
128    let include_patterns: Vec<glob::Pattern> = patterns
129        .iter()
130        .map(|p| {
131            glob::Pattern::new(p).map_err(|e| CopyError::InvalidGlobPattern {
132                pattern: p.clone(),
133                source: e,
134            })
135        })
136        .collect::<std::result::Result<Vec<_>, CopyError>>()?;
137
138    // Compile exclude patterns once (previously compiled per-file — now O(1) per check).
139    let exclude_patterns: Vec<glob::Pattern> = excludes
140        .iter()
141        .map(|p| {
142            glob::Pattern::new(p).map_err(|e| CopyError::InvalidGlobPattern {
143                pattern: p.clone(),
144                source: e,
145            })
146        })
147        .collect::<std::result::Result<Vec<_>, CopyError>>()?;
148
149    let match_opts = glob::MatchOptions {
150        case_sensitive: true,
151        require_literal_separator: false,
152        require_literal_leading_dot: false,
153    };
154
155    // Build walker. Include hidden files (e.g., .env, .vscode/).
156    // By default, respects .gitignore — never descends into node_modules/, target/, etc.
157    // With include_ignored, disable all git-based filtering to visit ignored files too.
158    let mut builder = ignore::WalkBuilder::new(from_path);
159    builder.hidden(false);
160    if include_ignored {
161        builder
162            .git_ignore(false)
163            .git_global(false)
164            .git_exclude(false);
165    }
166
167    let mut copied_files = Vec::new();
168
169    for entry in builder.build() {
170        let entry = match entry {
171            Ok(e) => e,
172            Err(e) => {
173                log::debug!("Walk error: {}", e);
174                continue;
175            }
176        };
177
178        // Skip directories
179        if entry.file_type().is_none_or(|ft| ft.is_dir()) {
180            continue;
181        }
182
183        let path = entry.path();
184
185        // Get relative path from from_path
186        let rel_path = match path.strip_prefix(from_path) {
187            Ok(p) => p.to_path_buf(),
188            Err(_) => continue,
189        };
190
191        let rel_path_str = match rel_path.to_str() {
192            Some(s) => s,
193            None => continue,
194        };
195
196        // Skip .git entry — in worktrees this is a file (not a directory) containing
197        // a gitdir pointer, so the directory check above doesn't catch it. Copying it
198        // would corrupt the destination worktree's git pointer.
199        if rel_path == Path::new(".git") {
200            continue;
201        }
202
203        // Skip files tracked in the git index (handles `git add -f`'d ignored files correctly)
204        if index.get_path(&rel_path, 0).is_some() {
205            on_skipped("tracked", &rel_path);
206            continue;
207        }
208
209        // Apply include patterns (empty = match all)
210        if !include_patterns.is_empty()
211            && !include_patterns
212                .iter()
213                .any(|p| p.matches_with(rel_path_str, match_opts))
214        {
215            continue;
216        }
217
218        // Apply exclude patterns
219        if exclude_patterns
220            .iter()
221            .any(|p| p.matches_with(rel_path_str, match_opts))
222        {
223            continue;
224        }
225
226        let dest_file = to_path.join(&rel_path);
227
228        // Skip if destination exists and not forcing
229        if dest_file.exists() && !force {
230            on_skipped("exists", &rel_path);
231            continue;
232        }
233
234        // Create parent directories if needed
235        if let Some(parent) = dest_file.parent() {
236            fs::create_dir_all(parent)?;
237        }
238
239        copy_file_platform(path, &dest_file)?;
240        on_copied(&rel_path);
241        copied_files.push(rel_path);
242    }
243
244    Ok(copied_files)
245}
246
247/// Copy a file using platform-specific copy-on-write when available.
248///
249/// Uses direct syscalls to avoid per-file subprocess overhead:
250/// - macOS: `clonefile(2)` for instant CoW on APFS; falls back to `fs::copy`
251/// - Linux: `ioctl(FICLONE)` for CoW on btrfs/XFS; falls back to `fs::copy`
252/// - Other: `fs::copy`
253#[cfg(target_os = "macos")]
254fn copy_file_platform(src: &Path, dest: &Path) -> Result<()> {
255    use std::ffi::CString;
256    use std::os::unix::ffi::OsStrExt;
257
258    let src_c = CString::new(src.as_os_str().as_bytes()).map_err(|_| CopyError::CopyFailed {
259        src: src.to_path_buf(),
260        dest: dest.to_path_buf(),
261        source: std::io::Error::from(std::io::ErrorKind::InvalidInput),
262    })?;
263    let dest_c = CString::new(dest.as_os_str().as_bytes()).map_err(|_| CopyError::CopyFailed {
264        src: src.to_path_buf(),
265        dest: dest.to_path_buf(),
266        source: std::io::Error::from(std::io::ErrorKind::InvalidInput),
267    })?;
268
269    // clonefile(2): instant CoW copy on APFS; fails on non-APFS or cross-device
270    if unsafe { libc::clonefile(src_c.as_ptr(), dest_c.as_ptr(), 0) } == 0 {
271        return Ok(());
272    }
273
274    // Fall back to standard copy (non-APFS, cross-filesystem, etc.)
275    fs::copy(src, dest)
276        .map(|_| ())
277        .map_err(|e| CopyError::CopyFailed {
278            src: src.to_path_buf(),
279            dest: dest.to_path_buf(),
280            source: e,
281        })
282        .map_err(Into::into)
283}
284
285#[cfg(target_os = "linux")]
286fn copy_file_platform(src: &Path, dest: &Path) -> Result<()> {
287    use std::fs::{File, OpenOptions};
288    use std::os::unix::io::AsRawFd;
289
290    // FICLONE ioctl: _IOW(0x94, 9, int) = 0x40049409
291    // Performs a reflink copy on btrfs/XFS; fails on unsupported filesystems
292    const FICLONE: libc::c_ulong = 0x40049409;
293
294    if let (Ok(src_file), Ok(dest_file)) = (
295        File::open(src),
296        OpenOptions::new()
297            .write(true)
298            .create(true)
299            .truncate(true)
300            .open(dest),
301    ) {
302        if unsafe { libc::ioctl(dest_file.as_raw_fd(), FICLONE, src_file.as_raw_fd()) } == 0 {
303            return Ok(());
304        }
305        // ioctl failed — dest file is open but may be empty, drop before overwriting
306        drop(dest_file);
307    }
308
309    // Fall back to standard copy (non-btrfs/XFS, cross-filesystem, etc.)
310    fs::copy(src, dest)
311        .map(|_| ())
312        .map_err(|e| CopyError::CopyFailed {
313            src: src.to_path_buf(),
314            dest: dest.to_path_buf(),
315            source: e,
316        })
317        .map_err(Into::into)
318}
319
320#[cfg(not(any(target_os = "macos", target_os = "linux")))]
321fn copy_file_platform(src: &Path, dest: &Path) -> Result<()> {
322    fs::copy(src, dest)
323        .map(|_| ())
324        .map_err(|e| CopyError::CopyFailed {
325            src: src.to_path_buf(),
326            dest: dest.to_path_buf(),
327            source: e,
328        })
329        .map_err(Into::into)
330}