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 files tracked in the git index (handles `git add -f`'d ignored files correctly)
197        if index.get_path(&rel_path, 0).is_some() {
198            on_skipped("tracked", &rel_path);
199            continue;
200        }
201
202        // Apply include patterns (empty = match all)
203        if !include_patterns.is_empty()
204            && !include_patterns
205                .iter()
206                .any(|p| p.matches_with(rel_path_str, match_opts))
207        {
208            continue;
209        }
210
211        // Apply exclude patterns
212        if exclude_patterns
213            .iter()
214            .any(|p| p.matches_with(rel_path_str, match_opts))
215        {
216            continue;
217        }
218
219        let dest_file = to_path.join(&rel_path);
220
221        // Skip if destination exists and not forcing
222        if dest_file.exists() && !force {
223            on_skipped("exists", &rel_path);
224            continue;
225        }
226
227        // Create parent directories if needed
228        if let Some(parent) = dest_file.parent() {
229            fs::create_dir_all(parent)?;
230        }
231
232        copy_file_platform(path, &dest_file)?;
233        on_copied(&rel_path);
234        copied_files.push(rel_path);
235    }
236
237    Ok(copied_files)
238}
239
240/// Copy a file using platform-specific copy-on-write when available.
241///
242/// Uses direct syscalls to avoid per-file subprocess overhead:
243/// - macOS: `clonefile(2)` for instant CoW on APFS; falls back to `fs::copy`
244/// - Linux: `ioctl(FICLONE)` for CoW on btrfs/XFS; falls back to `fs::copy`
245/// - Other: `fs::copy`
246#[cfg(target_os = "macos")]
247fn copy_file_platform(src: &Path, dest: &Path) -> Result<()> {
248    use std::ffi::CString;
249    use std::os::unix::ffi::OsStrExt;
250
251    let src_c = CString::new(src.as_os_str().as_bytes()).map_err(|_| CopyError::CopyFailed {
252        src: src.to_path_buf(),
253        dest: dest.to_path_buf(),
254        source: std::io::Error::from(std::io::ErrorKind::InvalidInput),
255    })?;
256    let dest_c = CString::new(dest.as_os_str().as_bytes()).map_err(|_| CopyError::CopyFailed {
257        src: src.to_path_buf(),
258        dest: dest.to_path_buf(),
259        source: std::io::Error::from(std::io::ErrorKind::InvalidInput),
260    })?;
261
262    // clonefile(2): instant CoW copy on APFS; fails on non-APFS or cross-device
263    if unsafe { libc::clonefile(src_c.as_ptr(), dest_c.as_ptr(), 0) } == 0 {
264        return Ok(());
265    }
266
267    // Fall back to standard copy (non-APFS, cross-filesystem, etc.)
268    fs::copy(src, dest)
269        .map(|_| ())
270        .map_err(|e| CopyError::CopyFailed {
271            src: src.to_path_buf(),
272            dest: dest.to_path_buf(),
273            source: e,
274        })
275        .map_err(Into::into)
276}
277
278#[cfg(target_os = "linux")]
279fn copy_file_platform(src: &Path, dest: &Path) -> Result<()> {
280    use std::fs::{File, OpenOptions};
281    use std::os::unix::io::AsRawFd;
282
283    // FICLONE ioctl: _IOW(0x94, 9, int) = 0x40049409
284    // Performs a reflink copy on btrfs/XFS; fails on unsupported filesystems
285    const FICLONE: libc::c_ulong = 0x40049409;
286
287    if let (Ok(src_file), Ok(dest_file)) = (
288        File::open(src),
289        OpenOptions::new()
290            .write(true)
291            .create(true)
292            .truncate(true)
293            .open(dest),
294    ) {
295        if unsafe { libc::ioctl(dest_file.as_raw_fd(), FICLONE, src_file.as_raw_fd()) } == 0 {
296            return Ok(());
297        }
298        // ioctl failed — dest file is open but may be empty, drop before overwriting
299        drop(dest_file);
300    }
301
302    // Fall back to standard copy (non-btrfs/XFS, cross-filesystem, etc.)
303    fs::copy(src, dest)
304        .map(|_| ())
305        .map_err(|e| CopyError::CopyFailed {
306            src: src.to_path_buf(),
307            dest: dest.to_path_buf(),
308            source: e,
309        })
310        .map_err(Into::into)
311}
312
313#[cfg(not(any(target_os = "macos", target_os = "linux")))]
314fn copy_file_platform(src: &Path, dest: &Path) -> Result<()> {
315    fs::copy(src, dest)
316        .map(|_| ())
317        .map_err(|e| CopyError::CopyFailed {
318            src: src.to_path_buf(),
319            dest: dest.to_path_buf(),
320            source: e,
321        })
322        .map_err(Into::into)
323}