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}