//! Skip logic for copy operations (gitignore + additional patterns). use std::path::{Path, PathBuf}; use std::sync::Arc; use anyhow::Result; use dashmap::DashSet; use ignore::{WalkBuilder, WalkState}; /// Build a globset matcher for skip patterns. pub(crate) fn build_skip_matcher(patterns: &[String]) -> Result { let mut builder = globset::GlobSetBuilder::new(); for pattern in patterns { builder.add(globset::Glob::new(pattern)?); } Ok(builder.build()?) } /// Collect all *unignored* paths in `source` (relative). /// /// This is used to implement an "ignored-only" copy: by collecting unignored paths /// and then skipping them during a second pass with `respect_gitignore=false`. pub(crate) fn collect_unignored_paths( source: &Path, parallelism: usize, ) -> Result> { let unignored: Arc> = Arc::new(DashSet::new()); // git_exclude/git_global off: external tools append broad patterns (e.g. // *.zip) to `.git/info/exclude`; the `ignore` crate would then drop matching // TRACKED files from the unignored set, so the ignored-copy clobbers them. let walker = WalkBuilder::new(source) .hidden(false) .git_ignore(true) .git_global(false) .git_exclude(false) .filter_entry(|entry| entry.file_name() != ".git") .threads(parallelism) .build_parallel(); walker.run(|| { let unignored = Arc::clone(&unignored); Box::new(move |entry_result| { let entry = match entry_result { Ok(e) => e, Err(_) => return WalkState::Continue, }; let rel_path = match entry.path().strip_prefix(source) { Ok(p) => p.to_path_buf(), Err(_) => return WalkState::Continue, }; unignored.insert(rel_path); WalkState::Continue }) }); Ok(match Arc::try_unwrap(unignored) { Ok(set) => set, Err(arc) => { let mut set = DashSet::new(); set.extend(arc.iter().map(|p| p.clone())); set } }) } #[cfg(test)] mod tests { use super::*; use kigi_test_utils::git::{git_commit_all, init_git_repo}; use tempfile::TempDir; #[test] fn collect_unignored_includes_tracked_file_matching_git_exclude() { kigi_test_utils::require_git!(); // A tracked file matching `.git/info/exclude` must stay "unignored" so // the ignored-copy doesn't re-copy and clobber it. let temp = TempDir::new().unwrap(); let repo = temp.path(); init_git_repo(repo); std::fs::write(repo.join("data.zip"), "tracked-archive").unwrap(); std::fs::write(repo.join("main.rs"), "fn main() {}").unwrap(); std::fs::write(repo.join(".gitignore"), "build/\n").unwrap(); git_commit_all(repo, "initial"); // External tooling appends broad patterns here (e.g., *.min.js, *.zip). // `git init` does not always create `.git/info/` (the hermetic git on // arm64 CI ships no init template), so create it before writing. let info_dir = repo.join(".git").join("info"); std::fs::create_dir_all(&info_dir).unwrap(); std::fs::write(info_dir.join("exclude"), "*.zip\n").unwrap(); // A truly-ignored (gitignored, untracked) artifact. std::fs::create_dir(repo.join("build")).unwrap(); std::fs::write(repo.join("build/out.o"), "obj").unwrap(); let unignored = collect_unignored_paths(repo, 1).unwrap(); assert!( unignored.contains(&PathBuf::from("data.zip")), "tracked file matching .git/info/exclude must be classed unignored" ); assert!(unignored.contains(&PathBuf::from("main.rs"))); assert!( !unignored.contains(&PathBuf::from("build/out.o")), "a real .gitignore'd file must remain ignored (not unignored)" ); } }