Skip to content

Commit

Permalink
feat: --copy-vcs (alias --copy-git) option (#472)
Browse files Browse the repository at this point in the history
  • Loading branch information
sourcefrog authored Dec 15, 2024
2 parents 196e9d2 + 5896390 commit 5b666ac
Show file tree
Hide file tree
Showing 8 changed files with 217 additions and 28 deletions.
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);
}
}

0 comments on commit 5b666ac

Please sign in to comment.