diff --git a/NEWS.md b/NEWS.md index 6a3449f1..a57586d1 100644 --- a/NEWS.md +++ b/NEWS.md @@ -4,6 +4,8 @@ - Better estimation of time remaining, based on the time taken to test mutants so far, excluding the time for the baseline. +- New: `--copy-vcs` option and config option will copy `.git` and other VCS directories, to accommodate trees whose tests depend on the contents or presence of the VCS directory. + ## 24.11.2 - Changed: `.gitignore` (and other git ignore files) are only consulted when copying the tree if it is contained within a directory with a `.git` directory. diff --git a/book/src/SUMMARY.md b/book/src/SUMMARY.md index e43e0e25..657cd221 100644 --- a/book/src/SUMMARY.md +++ b/book/src/SUMMARY.md @@ -17,7 +17,7 @@ - [Listing and previewing mutations](list.md) - [Workspaces and packages](workspaces.md) - [Passing options to Cargo](cargo-args.md) - - [Build directories](build-dirs.md) + - [Copying the tree](build-dirs.md) - [Using nextest](nextest.md) - [Baseline tests](baseline.md) - [Testing in-place](in-place.md) diff --git a/book/src/build-dirs.md b/book/src/build-dirs.md index 09e562eb..4585da15 100644 --- a/book/src/build-dirs.md +++ b/book/src/build-dirs.md @@ -1,10 +1,18 @@ -# Build directories +# Copying the tree -cargo-mutants builds mutated code in a temporary directory, containing a copy of your source tree with each mutant successively applied. With `--jobs`, multiple build directories are used in parallel. +By default, cargo-mutants copies your tree to a temporary directory before mutating and building it. This behavior is turned of by the [`--in-place`](in-place.md) option, which builds mutated code in the original source directory. -## Build-in ignores +When the [`--jobs`](parallelism.md) option is used, one build directory is created per job. -Files or directories matching these patterns are not copied: +Some filters are applied while copying the tree, which can be configured by options. + +## Troubleshooting tree copies + +If the baseline tests fail in the copied directory it is a good first debugging step to try building with `--in-place`. + +## `.git` and other version control directories + +By default, files or directories matching these patterns are not copied, because they can be large and typically are not needed to build the source: .git .hg @@ -13,7 +21,9 @@ Files or directories matching these patterns are not copied: _darcs .pijul -## gitignore +If your tree's build or tests require the VCS directory then it can be copied with `--copy-vcs=true` or by setting `copy_vcs = true` in `.cargo/mutants.toml`. + +## `.gitignore` From 23.11.2, by default, cargo-mutants will not copy files that are excluded by gitignore patterns, to make copying faster in large trees. @@ -22,3 +32,9 @@ gitignore filtering is only used within trees containing a `.git` directory. The filter, based on the [`ignore` crate](https://docs.rs/ignore/), also respects global git ignore configuration in the home directory, as well as `.gitignore` files within the tree. This behavior can be turned off with `--gitignore=false`, causing ignored files to be copied. + +Rust projects typically configure gitignore to exclude the `target/` directory. + +## `mutants.out` + +`mutants.out` and `mutants.out.old` are never copied, even if they're not covered by `.gitignore`. diff --git a/src/build_dir.rs b/src/build_dir.rs index 7cff8b71..2df19e95 100644 --- a/src/build_dir.rs +++ b/src/build_dir.rs @@ -57,7 +57,7 @@ impl BuildDir { let source_abs = source .canonicalize_utf8() .context("canonicalize source path")?; - let temp_dir = copy_tree(source, &name_base, options.gitignore, console)?; + let temp_dir = copy_tree(source, &name_base, options, console)?; let path: Utf8PathBuf = temp_dir .path() .to_owned() diff --git a/src/config.rs b/src/config.rs index 796d6094..e56b4919 100644 --- a/src/config.rs +++ b/src/config.rs @@ -29,6 +29,8 @@ use crate::Result; pub struct Config { /// Pass `--cap-lints` to rustc. pub cap_lints: bool, + /// Copy `.git` and other VCS directories to the build directory. + pub copy_vcs: Option, /// Generate these error values from functions returning Result. pub error_values: Vec, /// Generate mutants from source files matching these globs. diff --git a/src/copy_tree.rs b/src/copy_tree.rs index 37661d7c..71a20808 100644 --- a/src/copy_tree.rs +++ b/src/copy_tree.rs @@ -9,6 +9,7 @@ use path_slash::PathExt; use tempfile::TempDir; use tracing::{debug, warn}; +use crate::options::Options; use crate::{check_interrupted, Console, Result}; #[cfg(unix)] @@ -21,28 +22,13 @@ mod windows; #[cfg(windows)] use windows::copy_symlink; -/// Filenames excluded from being copied with the source. -static SOURCE_EXCLUDE: &[&str] = &[ - ".git", - ".hg", - ".bzr", - ".svn", - "_darcs", - ".pijul", - "mutants.out", - "mutants.out.old", -]; +static VCS_DIRS: &[&str] = &[".git", ".hg", ".bzr", ".svn", "_darcs", ".pijul"]; /// Copy a source tree, with some exclusions, to a new temporary directory. -/// -/// If `git` is true, ignore files that are excluded by all the various `.gitignore` -/// files. -/// -/// Regardless, anything matching [`SOURCE_EXCLUDE`] is excluded. pub fn copy_tree( from_path: &Utf8Path, name_base: &str, - gitignore: bool, + options: &Options, console: &Console, ) -> Result { let mut total_bytes = 0; @@ -58,13 +44,19 @@ pub fn copy_tree( .context("Convert path to UTF-8")?; console.start_copy(dest); let mut walk_builder = WalkBuilder::new(from_path); + let copy_vcs = options.copy_vcs; // for lifetime walk_builder - .standard_filters(gitignore) + .git_ignore(options.gitignore) + .git_exclude(options.gitignore) + .git_global(options.gitignore) .hidden(false) // copy hidden files .ignore(false) // don't use .ignore .require_git(true) // stop at git root; only read gitignore files inside git trees - .filter_entry(|entry| { - !SOURCE_EXCLUDE.contains(&entry.file_name().to_string_lossy().as_ref()) + .filter_entry(move |entry| { + let name = entry.file_name().to_string_lossy(); + name != "mutants.out" + && name != "mutants.out.old" + && (copy_vcs || !VCS_DIRS.contains(&name.as_ref())) }); debug!(?walk_builder); for entry in walk_builder.build() { @@ -115,12 +107,15 @@ pub fn copy_tree( #[cfg(test)] mod test { + // TODO: Maybe run these with $HOME set to a temp dir so that global git config has no effect? + use std::fs::{create_dir, write}; use camino::Utf8PathBuf; use tempfile::TempDir; use crate::console::Console; + use crate::options::Options; use crate::Result; use super::copy_tree; @@ -139,7 +134,8 @@ mod test { create_dir(&src)?; write(src.join("main.rs"), "fn main() {}")?; - let dest_tmpdir = copy_tree(&a, "a", true, &Console::new())?; + let options = Options::from_arg_strs(["--gitignore=true"]); + let dest_tmpdir = copy_tree(&a, "a", &options, &Console::new())?; let dest = dest_tmpdir.path(); assert!(dest.join("Cargo.toml").is_file()); assert!(dest.join("src").is_dir()); @@ -147,4 +143,141 @@ mod test { Ok(()) } + + /// With `gitignore` set to `true`, but no `.git`, don't exclude anything. + #[test] + fn copy_with_gitignore_but_without_git_dir() -> Result<()> { + let tmp_dir = TempDir::new().unwrap(); + let tmp = Utf8PathBuf::try_from(tmp_dir.path().to_owned()).unwrap(); + write(tmp.join(".gitignore"), "foo\n")?; + + write(tmp.join("Cargo.toml"), "[package]\nname = a")?; + let src = tmp.join("src"); + create_dir(&src)?; + write(src.join("main.rs"), "fn main() {}")?; + write(tmp.join("foo"), "bar")?; + + let options = Options::from_arg_strs(["--gitignore=true"]); + let dest_tmpdir = copy_tree(&tmp, "a", &options, &Console::new())?; + let dest = dest_tmpdir.path(); + assert!( + dest.join("foo").is_file(), + "foo should be copied because gitignore is not used without .git" + ); + + Ok(()) + } + + /// With `gitignore` set to `true`, in a tree with `.git`, `.gitignore` is respected. + #[test] + fn copy_with_gitignore_and_git_dir() -> Result<()> { + let tmp_dir = TempDir::new().unwrap(); + let tmp = Utf8PathBuf::try_from(tmp_dir.path().to_owned()).unwrap(); + write(tmp.join(".gitignore"), "foo\n")?; + create_dir(tmp.join(".git"))?; + + write(tmp.join("Cargo.toml"), "[package]\nname = a")?; + let src = tmp.join("src"); + create_dir(&src)?; + write(src.join("main.rs"), "fn main() {}")?; + write(tmp.join("foo"), "bar")?; + + let options = Options::from_arg_strs(["mutants", "--gitignore=true"]); + let dest_tmpdir = copy_tree(&tmp, "a", &options, &Console::new())?; + let dest = dest_tmpdir.path(); + assert!( + !dest.join("foo").is_file(), + "foo should have been excluded by gitignore" + ); + + Ok(()) + } + + /// With `gitignore` set to `false`, patterns in that file have no effect. + #[test] + fn copy_without_gitignore() -> Result<()> { + let tmp_dir = TempDir::new().unwrap(); + let tmp = Utf8PathBuf::try_from(tmp_dir.path().to_owned()).unwrap(); + write(tmp.join(".gitignore"), "foo\n")?; + create_dir(tmp.join(".git"))?; + + write(tmp.join("Cargo.toml"), "[package]\nname = a")?; + let src = tmp.join("src"); + create_dir(&src)?; + write(src.join("main.rs"), "fn main() {}")?; + write(tmp.join("foo"), "bar")?; + + let options = Options::from_arg_strs(["mutants", "--gitignore=false"]); + let dest_tmpdir = copy_tree(&tmp, "a", &options, &Console::new())?; + let dest = dest_tmpdir.path(); + // gitignore didn't exclude `foo` + assert!(dest.join("foo").is_file()); + + Ok(()) + } + + #[test] + fn dont_copy_git_dir_or_mutants_out_by_default() -> Result<()> { + let tmp_dir = TempDir::new().unwrap(); + let tmp = Utf8PathBuf::try_from(tmp_dir.path().to_owned()).unwrap(); + create_dir(tmp.join(".git"))?; + write(tmp.join(".git/foo"), "bar")?; + create_dir(tmp.join("mutants.out"))?; + write(tmp.join("mutants.out/foo"), "bar")?; + + write(tmp.join("Cargo.toml"), "[package]\nname = a")?; + let src = tmp.join("src"); + create_dir(&src)?; + write(src.join("main.rs"), "fn main() {}")?; + + let options = Options::from_arg_strs(["mutants"]); + let dest_tmpdir = copy_tree(&tmp, "a", &options, &Console::new())?; + let dest = dest_tmpdir.path(); + assert!(!dest.join(".git").is_dir(), ".git should not be copied"); + assert!( + !dest.join(".git/foo").is_file(), + ".git/foo should not be copied" + ); + assert!( + !dest.join("mutants.out").exists(), + "mutants.out should not be copied" + ); + assert!( + dest.join("Cargo.toml").is_file(), + "Cargo.toml should be copied" + ); + + Ok(()) + } + + #[test] + fn copy_git_dir_when_requested() -> Result<()> { + let tmp_dir = TempDir::new().unwrap(); + let tmp = Utf8PathBuf::try_from(tmp_dir.path().to_owned()).unwrap(); + create_dir(tmp.join(".git"))?; + write(tmp.join(".git/foo"), "bar")?; + create_dir(tmp.join("mutants.out"))?; + write(tmp.join("mutants.out/foo"), "bar")?; + + write(tmp.join("Cargo.toml"), "[package]\nname = a")?; + let src = tmp.join("src"); + create_dir(&src)?; + write(src.join("main.rs"), "fn main() {}")?; + + let options = Options::from_arg_strs(["mutants", "--copy-vcs=true"]); + let dest_tmpdir = copy_tree(&tmp, "a", &options, &Console::new())?; + let dest = dest_tmpdir.path(); + assert!(dest.join(".git").is_dir(), ".git should be copied"); + assert!(dest.join(".git/foo").is_file(), ".git/foo should be copied"); + assert!( + !dest.join("mutants.out").exists(), + "mutants.out should not be copied" + ); + assert!( + dest.join("Cargo.toml").is_file(), + "Cargo.toml should be copied" + ); + + Ok(()) + } } diff --git a/src/main.rs b/src/main.rs index 74629093..b6c495ad 100644 --- a/src/main.rs +++ b/src/main.rs @@ -144,6 +144,15 @@ pub struct Args { )] colors: Colors, + /// Copy `.git` and other VCS directories to the build directory. + /// + /// This is useful if you have tests that depend on the presence of these directories. + /// + /// Known VCS directories are + /// `.git`, `.hg`, `.bzr`, `.svn`, `_darcs`, `.pijul`. + #[arg(long, help_heading = "Copying", visible_alias = "copy_git")] + copy_vcs: Option, + /// Show the mutation diffs. #[arg(long, help_heading = "Filters")] diff: bool, diff --git a/src/options.rs b/src/options.rs index dfccdc7d..8ee391b4 100644 --- a/src/options.rs +++ b/src/options.rs @@ -41,6 +41,9 @@ pub struct Options { /// Don't run the tests, just see if each mutant builds. pub check_only: bool, + /// Copy `.git` and other VCS directories to build directories. + pub copy_vcs: bool, + /// Don't copy files matching gitignore patterns to build directories. pub gitignore: bool, @@ -285,6 +288,7 @@ impl Options { cap_lints: args.cap_lints.unwrap_or(config.cap_lints), check_only: args.check, colors: args.colors, + copy_vcs: args.copy_vcs.or(config.copy_vcs).unwrap_or(false), emit_json: args.json, emit_diffs: args.diff, error_values: join_slices(&args.error, &config.error_values), @@ -837,4 +841,27 @@ mod test { // In this case the default is not used assert_eq!(options.skip_calls, ["x", "y", "with_capacity"]); } + + #[test] + fn copy_vcs() { + let args = Args::parse_from(["mutants", "--copy-vcs=true"]); + let config = Config::default(); + let options = Options::new(&args, &config).unwrap(); + assert!(options.copy_vcs); + + let args = Args::parse_from(["mutants", "--copy-vcs=false"]); + let config = Config::default(); + let options = Options::new(&args, &config).unwrap(); + assert!(!options.copy_vcs); + + let args = Args::parse_from(["mutants"]); + let config = Config::from_str("copy_vcs = true").unwrap(); + let options = Options::new(&args, &config).unwrap(); + assert!(options.copy_vcs); + + let args = Args::parse_from(["mutants"]); + let config = Config::from_str("").unwrap(); + let options = Options::new(&args, &config).unwrap(); + assert!(!options.copy_vcs); + } }