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