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}