Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: --copy-vcs (alias --copy-git) option #472

Merged
merged 10 commits into from
Dec 15, 2024
2 changes: 2 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion book/src/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
26 changes: 21 additions & 5 deletions book/src/build-dirs.md
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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.

Expand All @@ -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`.
2 changes: 1 addition & 1 deletion src/build_dir.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
2 changes: 2 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<bool>,
/// Generate these error values from functions returning Result.
pub error_values: Vec<String>,
/// Generate mutants from source files matching these globs.
Expand Down
175 changes: 154 additions & 21 deletions src/copy_tree.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand All @@ -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<TempDir> {
let mut total_bytes = 0;
Expand All @@ -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() {
Expand Down Expand Up @@ -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;
Expand All @@ -139,12 +134,150 @@ 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());
assert!(dest.join("src/main.rs").is_file());

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(())
}
}
9 changes: 9 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<bool>,

/// Show the mutation diffs.
#[arg(long, help_heading = "Filters")]
diff: bool,
Expand Down
27 changes: 27 additions & 0 deletions src/options.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,

Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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);
}
}
Loading