Skip to content

Commit 5b666ac

Browse files
authored
feat: --copy-vcs (alias --copy-git) option (#472)
2 parents 196e9d2 + 5896390 commit 5b666ac

File tree

8 files changed

+217
-28
lines changed

8 files changed

+217
-28
lines changed

NEWS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
- Better estimation of time remaining, based on the time taken to test mutants so far, excluding the time for the baseline.
66

7+
- 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.
8+
79
## 24.11.2
810

911
- 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.

book/src/SUMMARY.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
- [Listing and previewing mutations](list.md)
1818
- [Workspaces and packages](workspaces.md)
1919
- [Passing options to Cargo](cargo-args.md)
20-
- [Build directories](build-dirs.md)
20+
- [Copying the tree](build-dirs.md)
2121
- [Using nextest](nextest.md)
2222
- [Baseline tests](baseline.md)
2323
- [Testing in-place](in-place.md)

book/src/build-dirs.md

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,18 @@
1-
# Build directories
1+
# Copying the tree
22

3-
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.
3+
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.
44

5-
## Build-in ignores
5+
When the [`--jobs`](parallelism.md) option is used, one build directory is created per job.
66

7-
Files or directories matching these patterns are not copied:
7+
Some filters are applied while copying the tree, which can be configured by options.
8+
9+
## Troubleshooting tree copies
10+
11+
If the baseline tests fail in the copied directory it is a good first debugging step to try building with `--in-place`.
12+
13+
## `.git` and other version control directories
14+
15+
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:
816

917
.git
1018
.hg
@@ -13,7 +21,9 @@ Files or directories matching these patterns are not copied:
1321
_darcs
1422
.pijul
1523

16-
## gitignore
24+
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`.
25+
26+
## `.gitignore`
1727

1828
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.
1929

@@ -22,3 +32,9 @@ gitignore filtering is only used within trees containing a `.git` directory.
2232
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.
2333

2434
This behavior can be turned off with `--gitignore=false`, causing ignored files to be copied.
35+
36+
Rust projects typically configure gitignore to exclude the `target/` directory.
37+
38+
## `mutants.out`
39+
40+
`mutants.out` and `mutants.out.old` are never copied, even if they're not covered by `.gitignore`.

src/build_dir.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ impl BuildDir {
5757
let source_abs = source
5858
.canonicalize_utf8()
5959
.context("canonicalize source path")?;
60-
let temp_dir = copy_tree(source, &name_base, options.gitignore, console)?;
60+
let temp_dir = copy_tree(source, &name_base, options, console)?;
6161
let path: Utf8PathBuf = temp_dir
6262
.path()
6363
.to_owned()

src/config.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ use crate::Result;
2929
pub struct Config {
3030
/// Pass `--cap-lints` to rustc.
3131
pub cap_lints: bool,
32+
/// Copy `.git` and other VCS directories to the build directory.
33+
pub copy_vcs: Option<bool>,
3234
/// Generate these error values from functions returning Result.
3335
pub error_values: Vec<String>,
3436
/// Generate mutants from source files matching these globs.

src/copy_tree.rs

Lines changed: 154 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ use path_slash::PathExt;
99
use tempfile::TempDir;
1010
use tracing::{debug, warn};
1111

12+
use crate::options::Options;
1213
use crate::{check_interrupted, Console, Result};
1314

1415
#[cfg(unix)]
@@ -21,28 +22,13 @@ mod windows;
2122
#[cfg(windows)]
2223
use windows::copy_symlink;
2324

24-
/// Filenames excluded from being copied with the source.
25-
static SOURCE_EXCLUDE: &[&str] = &[
26-
".git",
27-
".hg",
28-
".bzr",
29-
".svn",
30-
"_darcs",
31-
".pijul",
32-
"mutants.out",
33-
"mutants.out.old",
34-
];
25+
static VCS_DIRS: &[&str] = &[".git", ".hg", ".bzr", ".svn", "_darcs", ".pijul"];
3526

3627
/// Copy a source tree, with some exclusions, to a new temporary directory.
37-
///
38-
/// If `git` is true, ignore files that are excluded by all the various `.gitignore`
39-
/// files.
40-
///
41-
/// Regardless, anything matching [`SOURCE_EXCLUDE`] is excluded.
4228
pub fn copy_tree(
4329
from_path: &Utf8Path,
4430
name_base: &str,
45-
gitignore: bool,
31+
options: &Options,
4632
console: &Console,
4733
) -> Result<TempDir> {
4834
let mut total_bytes = 0;
@@ -58,13 +44,19 @@ pub fn copy_tree(
5844
.context("Convert path to UTF-8")?;
5945
console.start_copy(dest);
6046
let mut walk_builder = WalkBuilder::new(from_path);
47+
let copy_vcs = options.copy_vcs; // for lifetime
6148
walk_builder
62-
.standard_filters(gitignore)
49+
.git_ignore(options.gitignore)
50+
.git_exclude(options.gitignore)
51+
.git_global(options.gitignore)
6352
.hidden(false) // copy hidden files
6453
.ignore(false) // don't use .ignore
6554
.require_git(true) // stop at git root; only read gitignore files inside git trees
66-
.filter_entry(|entry| {
67-
!SOURCE_EXCLUDE.contains(&entry.file_name().to_string_lossy().as_ref())
55+
.filter_entry(move |entry| {
56+
let name = entry.file_name().to_string_lossy();
57+
name != "mutants.out"
58+
&& name != "mutants.out.old"
59+
&& (copy_vcs || !VCS_DIRS.contains(&name.as_ref()))
6860
});
6961
debug!(?walk_builder);
7062
for entry in walk_builder.build() {
@@ -115,12 +107,15 @@ pub fn copy_tree(
115107

116108
#[cfg(test)]
117109
mod test {
110+
// TODO: Maybe run these with $HOME set to a temp dir so that global git config has no effect?
111+
118112
use std::fs::{create_dir, write};
119113

120114
use camino::Utf8PathBuf;
121115
use tempfile::TempDir;
122116

123117
use crate::console::Console;
118+
use crate::options::Options;
124119
use crate::Result;
125120

126121
use super::copy_tree;
@@ -139,12 +134,150 @@ mod test {
139134
create_dir(&src)?;
140135
write(src.join("main.rs"), "fn main() {}")?;
141136

142-
let dest_tmpdir = copy_tree(&a, "a", true, &Console::new())?;
137+
let options = Options::from_arg_strs(["--gitignore=true"]);
138+
let dest_tmpdir = copy_tree(&a, "a", &options, &Console::new())?;
143139
let dest = dest_tmpdir.path();
144140
assert!(dest.join("Cargo.toml").is_file());
145141
assert!(dest.join("src").is_dir());
146142
assert!(dest.join("src/main.rs").is_file());
147143

148144
Ok(())
149145
}
146+
147+
/// With `gitignore` set to `true`, but no `.git`, don't exclude anything.
148+
#[test]
149+
fn copy_with_gitignore_but_without_git_dir() -> Result<()> {
150+
let tmp_dir = TempDir::new().unwrap();
151+
let tmp = Utf8PathBuf::try_from(tmp_dir.path().to_owned()).unwrap();
152+
write(tmp.join(".gitignore"), "foo\n")?;
153+
154+
write(tmp.join("Cargo.toml"), "[package]\nname = a")?;
155+
let src = tmp.join("src");
156+
create_dir(&src)?;
157+
write(src.join("main.rs"), "fn main() {}")?;
158+
write(tmp.join("foo"), "bar")?;
159+
160+
let options = Options::from_arg_strs(["--gitignore=true"]);
161+
let dest_tmpdir = copy_tree(&tmp, "a", &options, &Console::new())?;
162+
let dest = dest_tmpdir.path();
163+
assert!(
164+
dest.join("foo").is_file(),
165+
"foo should be copied because gitignore is not used without .git"
166+
);
167+
168+
Ok(())
169+
}
170+
171+
/// With `gitignore` set to `true`, in a tree with `.git`, `.gitignore` is respected.
172+
#[test]
173+
fn copy_with_gitignore_and_git_dir() -> Result<()> {
174+
let tmp_dir = TempDir::new().unwrap();
175+
let tmp = Utf8PathBuf::try_from(tmp_dir.path().to_owned()).unwrap();
176+
write(tmp.join(".gitignore"), "foo\n")?;
177+
create_dir(tmp.join(".git"))?;
178+
179+
write(tmp.join("Cargo.toml"), "[package]\nname = a")?;
180+
let src = tmp.join("src");
181+
create_dir(&src)?;
182+
write(src.join("main.rs"), "fn main() {}")?;
183+
write(tmp.join("foo"), "bar")?;
184+
185+
let options = Options::from_arg_strs(["mutants", "--gitignore=true"]);
186+
let dest_tmpdir = copy_tree(&tmp, "a", &options, &Console::new())?;
187+
let dest = dest_tmpdir.path();
188+
assert!(
189+
!dest.join("foo").is_file(),
190+
"foo should have been excluded by gitignore"
191+
);
192+
193+
Ok(())
194+
}
195+
196+
/// With `gitignore` set to `false`, patterns in that file have no effect.
197+
#[test]
198+
fn copy_without_gitignore() -> Result<()> {
199+
let tmp_dir = TempDir::new().unwrap();
200+
let tmp = Utf8PathBuf::try_from(tmp_dir.path().to_owned()).unwrap();
201+
write(tmp.join(".gitignore"), "foo\n")?;
202+
create_dir(tmp.join(".git"))?;
203+
204+
write(tmp.join("Cargo.toml"), "[package]\nname = a")?;
205+
let src = tmp.join("src");
206+
create_dir(&src)?;
207+
write(src.join("main.rs"), "fn main() {}")?;
208+
write(tmp.join("foo"), "bar")?;
209+
210+
let options = Options::from_arg_strs(["mutants", "--gitignore=false"]);
211+
let dest_tmpdir = copy_tree(&tmp, "a", &options, &Console::new())?;
212+
let dest = dest_tmpdir.path();
213+
// gitignore didn't exclude `foo`
214+
assert!(dest.join("foo").is_file());
215+
216+
Ok(())
217+
}
218+
219+
#[test]
220+
fn dont_copy_git_dir_or_mutants_out_by_default() -> Result<()> {
221+
let tmp_dir = TempDir::new().unwrap();
222+
let tmp = Utf8PathBuf::try_from(tmp_dir.path().to_owned()).unwrap();
223+
create_dir(tmp.join(".git"))?;
224+
write(tmp.join(".git/foo"), "bar")?;
225+
create_dir(tmp.join("mutants.out"))?;
226+
write(tmp.join("mutants.out/foo"), "bar")?;
227+
228+
write(tmp.join("Cargo.toml"), "[package]\nname = a")?;
229+
let src = tmp.join("src");
230+
create_dir(&src)?;
231+
write(src.join("main.rs"), "fn main() {}")?;
232+
233+
let options = Options::from_arg_strs(["mutants"]);
234+
let dest_tmpdir = copy_tree(&tmp, "a", &options, &Console::new())?;
235+
let dest = dest_tmpdir.path();
236+
assert!(!dest.join(".git").is_dir(), ".git should not be copied");
237+
assert!(
238+
!dest.join(".git/foo").is_file(),
239+
".git/foo should not be copied"
240+
);
241+
assert!(
242+
!dest.join("mutants.out").exists(),
243+
"mutants.out should not be copied"
244+
);
245+
assert!(
246+
dest.join("Cargo.toml").is_file(),
247+
"Cargo.toml should be copied"
248+
);
249+
250+
Ok(())
251+
}
252+
253+
#[test]
254+
fn copy_git_dir_when_requested() -> Result<()> {
255+
let tmp_dir = TempDir::new().unwrap();
256+
let tmp = Utf8PathBuf::try_from(tmp_dir.path().to_owned()).unwrap();
257+
create_dir(tmp.join(".git"))?;
258+
write(tmp.join(".git/foo"), "bar")?;
259+
create_dir(tmp.join("mutants.out"))?;
260+
write(tmp.join("mutants.out/foo"), "bar")?;
261+
262+
write(tmp.join("Cargo.toml"), "[package]\nname = a")?;
263+
let src = tmp.join("src");
264+
create_dir(&src)?;
265+
write(src.join("main.rs"), "fn main() {}")?;
266+
267+
let options = Options::from_arg_strs(["mutants", "--copy-vcs=true"]);
268+
let dest_tmpdir = copy_tree(&tmp, "a", &options, &Console::new())?;
269+
let dest = dest_tmpdir.path();
270+
assert!(dest.join(".git").is_dir(), ".git should be copied");
271+
assert!(dest.join(".git/foo").is_file(), ".git/foo should be copied");
272+
assert!(
273+
!dest.join("mutants.out").exists(),
274+
"mutants.out should not be copied"
275+
);
276+
assert!(
277+
dest.join("Cargo.toml").is_file(),
278+
"Cargo.toml should be copied"
279+
);
280+
281+
Ok(())
282+
}
150283
}

src/main.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,15 @@ pub struct Args {
144144
)]
145145
colors: Colors,
146146

147+
/// Copy `.git` and other VCS directories to the build directory.
148+
///
149+
/// This is useful if you have tests that depend on the presence of these directories.
150+
///
151+
/// Known VCS directories are
152+
/// `.git`, `.hg`, `.bzr`, `.svn`, `_darcs`, `.pijul`.
153+
#[arg(long, help_heading = "Copying", visible_alias = "copy_git")]
154+
copy_vcs: Option<bool>,
155+
147156
/// Show the mutation diffs.
148157
#[arg(long, help_heading = "Filters")]
149158
diff: bool,

src/options.rs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ pub struct Options {
4141
/// Don't run the tests, just see if each mutant builds.
4242
pub check_only: bool,
4343

44+
/// Copy `.git` and other VCS directories to build directories.
45+
pub copy_vcs: bool,
46+
4447
/// Don't copy files matching gitignore patterns to build directories.
4548
pub gitignore: bool,
4649

@@ -285,6 +288,7 @@ impl Options {
285288
cap_lints: args.cap_lints.unwrap_or(config.cap_lints),
286289
check_only: args.check,
287290
colors: args.colors,
291+
copy_vcs: args.copy_vcs.or(config.copy_vcs).unwrap_or(false),
288292
emit_json: args.json,
289293
emit_diffs: args.diff,
290294
error_values: join_slices(&args.error, &config.error_values),
@@ -837,4 +841,27 @@ mod test {
837841
// In this case the default is not used
838842
assert_eq!(options.skip_calls, ["x", "y", "with_capacity"]);
839843
}
844+
845+
#[test]
846+
fn copy_vcs() {
847+
let args = Args::parse_from(["mutants", "--copy-vcs=true"]);
848+
let config = Config::default();
849+
let options = Options::new(&args, &config).unwrap();
850+
assert!(options.copy_vcs);
851+
852+
let args = Args::parse_from(["mutants", "--copy-vcs=false"]);
853+
let config = Config::default();
854+
let options = Options::new(&args, &config).unwrap();
855+
assert!(!options.copy_vcs);
856+
857+
let args = Args::parse_from(["mutants"]);
858+
let config = Config::from_str("copy_vcs = true").unwrap();
859+
let options = Options::new(&args, &config).unwrap();
860+
assert!(options.copy_vcs);
861+
862+
let args = Args::parse_from(["mutants"]);
863+
let config = Config::from_str("").unwrap();
864+
let options = Options::new(&args, &config).unwrap();
865+
assert!(!options.copy_vcs);
866+
}
840867
}

0 commit comments

Comments
 (0)