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}