From b7263607e0293007593c797155a7bdb79251b3bf Mon Sep 17 00:00:00 2001 From: Pekka Enberg Date: Fri, 30 Oct 2026 09:36:08 +0200 Subject: [PATCH 1/2] Make fsfork and confine able to leave out what .gitignore files ignore A fork is everything in the directory, so a directory with a large `node_modules` or `target` takes seconds to fork, twice over, as the fork and its base each clone every file in it. On APFS, 200,000 ignored files took about 5 seconds. With the new `--skip-ignored` option of `fsfork` and `confine`, what `.gitignore` files ignore and git does not track is left out of the fork and its base, which took about 0.1 seconds on the same directory. It is an option, because warm builds are why a fork has build output. Git says what is ignored, so that the rules are its own, and the same as `fsdiff` goes by. It runs on a repository of `fsfork`'s own with no configuration, as `fsdiff`'s does, because the directory could be a fork that a confined command changed, and `confine` forks outside the sandbox. It reads the directory's index, so that tracked files stay. Directories with nothing ignored in them are still cloned whole, at once. --- README.md | 9 +- cmd/confine/confine.1 | 14 +++ cmd/confine/confine.rs | 19 +++- cmd/confine/tests/confine.rs | 25 +++++ cmd/fsfork/fsfork.1 | 43 +++++++- cmd/fsfork/fsfork.rs | 16 ++- cmd/fsfork/tests/fsfork.rs | 199 ++++++++++++++++++++++++++++++++++- fsfork/darwin.rs | 32 +++++- fsfork/fsfork.rs | 68 ++++++++++-- fsfork/ignored.rs | 184 ++++++++++++++++++++++++++++++++ fsfork/linux.rs | 24 ++++- 11 files changed, 615 insertions(+), 18 deletions(-) create mode 100644 fsfork/ignored.rs diff --git a/README.md b/README.md index 9e838c5..6bc37e2 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ They run on macOS and Linux. - Linux: Linux 5.19 or later, with Landlock enabled, for `confine`. A file system that can share blocks between files, such as Btrfs or XFS, for `fsfork`; on others, a fork is a full copy. -- `git`, for `fsdiff`. +- `git`, for `fsdiff` and `--skip-ignored`. ## fsfork @@ -85,7 +85,12 @@ made with `clonefile`, and on Linux with `cp --reflink`, so it takes no space until either side is written to. `fsfork` knows nothing about git: it forks exactly the directory it is -given. +given. The exception is `--skip-ignored`, for `fsfork` and `confine`, which +leaves out what `.gitignore` files ignore and git does not track, such as +`target` or `node_modules`. The fork is quicker to make if those hold many +files, and builds in it start cold. Git runs in a repository of `fsfork`'s +own, as with `fsdiff`, so the directory's configuration cannot make it run +anything. ## fsdiff diff --git a/cmd/confine/confine.1 b/cmd/confine/confine.1 index fda57ce..ffcfe3f 100644 --- a/cmd/confine/confine.1 +++ b/cmd/confine/confine.1 @@ -7,6 +7,7 @@ confine \- run a command that may write only to a fork of a directory .IR path ]... .RB [ \-r .IR path ]... +.RB [ \-\-skip\-ignored ] .RB [ \-\-rm | \-d .IR dir ] .RI [ command @@ -75,6 +76,19 @@ It cannot be given with .BR \-d , as there is no fork to remove. .TP +.B \-\-skip\-ignored +Leave out of the fork what +.I .gitignore +files ignore and +.BR git (1) +does not track, as +.B fsfork \-\-skip\-ignored +does: see +.BR fsfork (1). +It cannot be given with +.BR \-d , +as there is no fork. +.TP .BI \-r " path" "\fR, \fP\-\-read " path Let the command read .I path diff --git a/cmd/confine/confine.rs b/cmd/confine/confine.rs index 8db0fa2..041cb6f 100644 --- a/cmd/confine/confine.rs +++ b/cmd/confine/confine.rs @@ -24,6 +24,7 @@ use std::{ }; use argh::FromArgs; +use fsfork::Contents; /// Directories under the home directory that the command may write to, so /// that builds and package managers keep working. @@ -80,6 +81,11 @@ struct Args { #[argh(switch)] rm: bool, + /// leave out of the fork what .gitignore files ignore, unless it is + /// tracked + #[argh(switch)] + skip_ignored: bool, + /// the command to run and its arguments; the shell if none #[argh(positional, greedy)] command: Vec, @@ -99,6 +105,12 @@ fn main() -> ExitCode { eprintln!("confine: --rm removes a fork, and -d runs the command without one"); return ExitCode::FAILURE; } + if args.skip_ignored && args.dir.is_some() { + eprintln!( + "confine: --skip-ignored leaves things out of a fork, and -d runs the command without one" + ); + return ExitCode::FAILURE; + } let e = match confine(&args) { Ok(Ran::Exited(status)) => return exit_as(status), Ok(Ran::Failed(e)) => e, @@ -155,7 +167,12 @@ fn confine(args: &Args) -> io::Result { } return run(&command, &dir, &writable, &readable, &hidden, false); } - let fork = fsfork::fork(Path::new("."))?; + let contents = if args.skip_ignored { + Contents::NotIgnored + } else { + Contents::Everything + }; + let fork = fsfork::fork(Path::new("."), contents)?; eprintln!("confine: forked to {}", fork.display()); let ran = run(&command, &fork, &writable, &readable, &hidden, args.rm); if (args.rm || !matches!(ran, Ok(Ran::Exited(_)))) diff --git a/cmd/confine/tests/confine.rs b/cmd/confine/tests/confine.rs index 5e03006..2155e2a 100644 --- a/cmd/confine/tests/confine.rs +++ b/cmd/confine/tests/confine.rs @@ -108,6 +108,31 @@ fn removes_the_fork_after_the_command_with_rm() { assert_no_forks(&world); } +#[test] +fn leaves_what_is_ignored_out_of_the_fork_with_skip_ignored() { + need_sandbox!(); + let world = World::new(); + let dir = world.dir("project"); + write(&dir, ".gitignore", "target/\n"); + write(&dir, "a", "a\n"); + write(&dir, "target/out", "built\n"); + let out = world.confine(&dir, &["--skip-ignored", "sh", "-c", "cat a; ls target"]); + assert!(!out.status.success()); + assert_eq!(stdout(&out), "a\n"); + assert!(!world.dir.join("project.1/target").exists()); + assert!(dir.join("target/out").exists()); +} + +#[test] +fn skip_ignored_cannot_be_given_with_d() { + let world = World::new(); + let dir = world.dir("project"); + assert_fails( + &world.confine(&dir, &["--skip-ignored", "-d", ".", "true"]), + "--skip-ignored", + ); +} + #[test] fn rm_cannot_be_given_with_d() { let world = World::new(); diff --git a/cmd/fsfork/fsfork.1 b/cmd/fsfork/fsfork.1 index 1afa182..84902d6 100644 --- a/cmd/fsfork/fsfork.1 +++ b/cmd/fsfork/fsfork.1 @@ -3,6 +3,7 @@ fsfork \- fork a directory: make a copy-on-write clone of it .SH SYNOPSIS .B fsfork +.RB [ \-\-skip\-ignored ] .RI [ dir .RI [ fork ]] .SH DESCRIPTION @@ -16,7 +17,9 @@ goes there. .PP The fork is everything in the directory, as it is: hidden files, the git repository, uncommitted changes, and build output, so builds in the fork -start warm. +start warm, unless +.B \-\-skip\-ignored +is given. Making it is quick, and it takes no space until either it or the directory is written to. From then on, the two are separate: what changes in one does not change in @@ -56,10 +59,35 @@ lists them, removes them. Remove a fork's base with it, or its number stays taken. .PP +Without +.BR \-\-skip\-ignored , .B fsfork knows nothing about git, or what is in the directory: it forks exactly .IR dir . In a subdirectory of a repository, it forks the subdirectory. +.SH OPTIONS +.TP +.B \-\-skip\-ignored +Leave out of the fork and its base what +.I .gitignore +files ignore, such as build output and dependencies, and +.BR git (1) +does not track. +Everything else is cloned as without it, the git repository included. +The fork is quicker to make if what is left out holds many files, and its +builds start cold, without files such as +.I .env +if they are ignored. +Only +.I .gitignore +files count, not +.I .git/info/exclude +or a global excludes file. +It needs +.BR git (1), +which it runs without the repository's configuration, so that nothing in +.I dir +can make it run a command. .SH EXIT STATUS 0 if the directory was forked, and 1 otherwise, having made neither the fork nor the base. @@ -91,6 +119,12 @@ and remove one: .B rm \-rf turso.1 .turso.1.base .fi .RE +.PP +Fork the working directory, without its build output: +.PP +.RS +.B cd $(fsfork \-\-skip\-ignored) +.RE .SH CAVEATS On macOS, the fork is made with .BR clonefile (2), @@ -102,9 +136,16 @@ files, such as Btrfs or XFS; elsewhere, it is a full copy. .PP Hard links in the directory are not hard links in the fork. .PP +With +.BR \-\-skip\-ignored , +a directory with something ignored in it is made anew, with its permissions +and modification time, but not its other attributes, and on macOS, sockets +and devices beside what is ignored are left out. +.PP The fork is at another path than the directory, so tools that remember absolute paths, such as build caches, may rebuild in it. .SH SEE ALSO .BR confine (1), .BR fsdiff (1), +.BR git (1), .BR clonefile (2) diff --git a/cmd/fsfork/fsfork.rs b/cmd/fsfork/fsfork.rs index e95dbd3..699bb36 100644 --- a/cmd/fsfork/fsfork.rs +++ b/cmd/fsfork/fsfork.rs @@ -10,10 +10,15 @@ use std::{ }; use argh::FromArgs; +use fsfork::Contents; /// Fork a directory: make a copy-on-write clone of it, and print where it is. #[derive(FromArgs)] struct Args { + /// leave out what .gitignore files ignore, unless it is tracked + #[argh(switch)] + skip_ignored: bool, + /// the directory to fork, the working directory if none; then where to /// put the fork, which must not exist, or next to the directory, named /// after it and the first number not taken, if not given @@ -42,10 +47,15 @@ fn main() -> ExitCode { /// Forks the directory `args` give, and returns where the fork is. fn fsfork(args: Args) -> io::Result { + let contents = if args.skip_ignored { + Contents::NotIgnored + } else { + Contents::Everything + }; match &args.paths[..] { - [] => fsfork::fork(Path::new(".")), - [dir] => fsfork::fork(dir), - [dir, fork] => fsfork::fork_to(dir, fork), + [] => fsfork::fork(Path::new("."), contents), + [dir] => fsfork::fork(dir, contents), + [dir, fork] => fsfork::fork_to(dir, fork, contents), _ => Err(io::Error::other( "too many arguments: a directory, and a fork", )), diff --git a/cmd/fsfork/tests/fsfork.rs b/cmd/fsfork/tests/fsfork.rs index 63ca9ef..b11a6d0 100644 --- a/cmd/fsfork/tests/fsfork.rs +++ b/cmd/fsfork/tests/fsfork.rs @@ -2,10 +2,13 @@ //! `fsfork.1` says it does. use std::{ - fs, - os::unix::fs::{PermissionsExt, symlink}, + env, fs, + os::unix::{ + fs::{FileTypeExt, PermissionsExt, symlink}, + net::UnixListener, + }, path::{Path, PathBuf}, - process::{Command, Output}, + process::{self, Command, Output}, thread, }; @@ -88,6 +91,186 @@ fn copies_everything_in_the_directory_as_it_is() { assert_eq!(fs::read_link(fork.join("link")).unwrap(), Path::new("a")); } +#[test] +fn leaves_out_what_gitignore_files_ignore_with_skip_ignored() { + let world = World::new(); + let dir = world.project(); + write(&dir, ".gitignore", "target/\n*.log\nbuild/*\n!build/keep\n"); + write(&dir, "target/deep/out", "built\n"); + write(&dir, "kept/b", "two\n"); + write(&dir, "kept/.gitignore", "only-here\n"); + write(&dir, "kept/only-here", "ignored\n"); + write(&dir, "kept/run.log", "ignored\n"); + write(&dir, "build/keep", "kept\n"); + write(&dir, "build/out", "ignored\n"); + fs::create_dir(dir.join("empty")).unwrap(); + symlink("a", dir.join("kept/link")).unwrap(); + fs::set_permissions(dir.join("kept"), fs::Permissions::from_mode(0o750)).unwrap(); + let out = fsfork(&dir, &["--skip-ignored"]); + assert!(out.status.success(), "{}", stderr(&out)); + for tree in [world.path("project.1"), world.path(".project.1.base")] { + assert_eq!(read(&tree, "a"), "one\n"); + assert_eq!( + read(&tree, ".gitignore"), + "target/\n*.log\nbuild/*\n!build/keep\n" + ); + assert_eq!(read(&tree, "kept/b"), "two\n"); + assert_eq!(read(&tree, "build/keep"), "kept\n"); + assert_eq!( + fs::read_link(tree.join("kept/link")).unwrap(), + Path::new("a") + ); + assert!(tree.join("empty").is_dir()); + assert!(!tree.join("target").exists()); + assert!(!tree.join("kept/only-here").exists()); + assert!(!tree.join("kept/run.log").exists()); + assert!(!tree.join("build/out").exists()); + let mode = fs::metadata(tree.join("kept")) + .unwrap() + .permissions() + .mode(); + assert_eq!(mode & 0o777, 0o750); + } + assert_eq!(read(&dir, "target/deep/out"), "built\n"); +} + +#[test] +fn keeps_tracked_files_and_the_repository_with_skip_ignored() { + let world = World::new(); + let dir = world.project(); + git(&dir, &["init", "--quiet"]); + write(&dir, ".gitignore", "*\n!.gitignore\n"); + write(&dir, "tracked/forced", "tracked\n"); + write(&dir, "tracked/other", "ignored\n"); + git(&dir, &["add", ".gitignore"]); + git(&dir, &["add", "--force", "tracked/forced"]); + let fork = PathBuf::from(stdout(&fsfork(&dir, &["--skip-ignored"])).trim_end()); + assert_eq!(read(&fork, "tracked/forced"), "tracked\n"); + assert!(!fork.join("tracked/other").exists()); + assert!(!fork.join("a").exists()); + assert!(fork.join(".git/config").exists()); +} + +#[test] +fn skips_what_is_ignored_in_a_subdirectory_and_a_worktree_of_a_repository() { + let world = World::new(); + let dir = world.project(); + git(&dir, &["init", "--quiet"]); + write(&dir, ".gitignore", "*.o\n"); + write(&dir, "sub/x.o", "ignored\n"); + write(&dir, "sub/forced.o", "tracked\n"); + write(&dir, "sub/c", "c\n"); + git(&dir, &["add", "."]); + git(&dir, &["add", "--force", "sub/forced.o"]); + git( + &dir, + &[ + "-c", + "user.name=t", + "-c", + "user.email=t@t", + "commit", + "-qm", + "c", + ], + ); + let fork = PathBuf::from(stdout(&fsfork(&dir.join("sub"), &["--skip-ignored"])).trim_end()); + assert_eq!(read(&fork, "c"), "c\n"); + assert_eq!(read(&fork, "forced.o"), "tracked\n"); + assert!(!fork.join("x.o").exists()); + let worktree = world.path("worktree"); + git( + &dir, + &["worktree", "add", "--quiet", worktree.to_str().unwrap()], + ); + write(&worktree, "sub/y.o", "ignored\n"); + let fork = PathBuf::from(stdout(&fsfork(&worktree, &["--skip-ignored"])).trim_end()); + assert_eq!(read(&fork, "sub/forced.o"), "tracked\n"); + assert!(!fork.join("sub/y.o").exists()); +} + +#[test] +fn skip_ignored_takes_fifos_and_sockets_beside_what_is_ignored() { + let world = World::new(); + let dir = world.project(); + write(&dir, ".gitignore", "target/\n"); + write(&dir, "target/out", "built\n"); + let made = Command::new("mkfifo") + .arg(dir.join("pipe")) + .status() + .unwrap(); + assert!(made.success()); + // The path to a socket is limited in length, which the world's exceeds. + let short = env::temp_dir().join(format!("fsfork-test.{}", process::id())); + symlink(&dir, &short).unwrap(); + let socket = UnixListener::bind(short.join("sock")); + fs::remove_file(&short).unwrap(); + let _socket = socket.unwrap(); + let out = fsfork(&dir, &["--skip-ignored"]); + assert!(out.status.success(), "{}", stderr(&out)); + let fork = world.path("project.1"); + let pipe = fork.join("pipe").symlink_metadata().unwrap(); + assert!(pipe.file_type().is_fifo()); + assert_eq!(read(&fork, "a"), "one\n"); +} + +#[test] +fn a_directory_that_is_ignored_itself_goes_by_its_own_gitignore_files() { + let world = World::new(); + let dir = world.project(); + git(&dir, &["init", "--quiet"]); + write(&dir, ".gitignore", "vendor/\n"); + write(&dir, "vendor/lib/.gitignore", "out/\n"); + write(&dir, "vendor/lib/src", "src\n"); + write(&dir, "vendor/lib/out/o", "built\n"); + let lib = dir.join("vendor/lib"); + let fork = PathBuf::from(stdout(&fsfork(&lib, &["--skip-ignored"])).trim_end()); + assert_eq!(read(&fork, "src"), "src\n"); + assert!(!fork.join("out").exists()); +} + +#[test] +fn skip_ignored_does_not_run_what_the_repository_configures() { + let world = World::new(); + let dir = world.project(); + git(&dir, &["init", "--quiet"]); + write(&dir, ".gitignore", "target/\n"); + write(&dir, "target/out", "built\n"); + let ran = world.path("ran"); + let monitor = world.path("monitor"); + write( + &world.dir, + "monitor", + &format!("#!/bin/sh\ntouch {}\n", ran.display()), + ); + fs::set_permissions(&monitor, fs::Permissions::from_mode(0o755)).unwrap(); + git( + &dir, + &["config", "core.fsmonitor", monitor.to_str().unwrap()], + ); + let out = fsfork(&dir, &["--skip-ignored"]); + assert!(out.status.success(), "{}", stderr(&out)); + assert!(!world.path("project.1/target").exists()); + assert!(!ran.exists()); +} + +#[test] +fn fails_with_skip_ignored_if_the_fork_or_its_base_exists() { + let world = World::new(); + let dir = world.project(); + write(&dir, ".gitignore", "target/\n"); + write(&dir, "target/out", "built\n"); + write(&world.path("parser"), "mine", "mine\n"); + write(&world.path(".lexer.base"), "mine", "mine\n"); + let args = |fork| ["--skip-ignored", "project", fork]; + assert_fails(&fsfork(&world.dir, &args("parser")), "exists"); + assert_eq!(read(&world.path("parser"), "mine"), "mine\n"); + assert!(!world.path(".parser.base").exists()); + assert_fails(&fsfork(&world.dir, &args("lexer")), "exists"); + assert_eq!(read(&world.path(".lexer.base"), "mine"), "mine\n"); + assert!(!world.path("lexer").exists()); +} + #[test] fn the_directory_the_fork_and_the_base_change_apart() { let world = World::new(); @@ -184,6 +367,16 @@ fn fsfork(cwd: &Path, args: &[&str]) -> Output { .unwrap() } +/// Runs git with `args` in `dir`, which must succeed. +fn git(dir: &Path, args: &[&str]) { + let out = Command::new("git") + .args(args) + .current_dir(dir) + .output() + .unwrap(); + assert!(out.status.success(), "{}", stderr(&out)); +} + /// Writes `contents` to `name` in `dir`. fn write(dir: &Path, name: &str, contents: &str) { let path = dir.join(name); diff --git a/fsfork/darwin.rs b/fsfork/darwin.rs index 77f99f1..2741e6d 100644 --- a/fsfork/darwin.rs +++ b/fsfork/darwin.rs @@ -1,6 +1,14 @@ //! macOS: a directory is cloned with `clonefile`. -use std::{ffi::CString, io, os::unix::ffi::OsStrExt, path::Path}; +use std::{ + ffi::{CString, OsString}, + io, + os::unix::{ + ffi::OsStrExt, + fs::{FileTypeExt, MetadataExt}, + }, + path::Path, +}; /// Copies symbolic links rather than what they point to. const CLONE_NOFOLLOW: u32 = 0x0001; @@ -15,3 +23,25 @@ pub fn clone(from: &Path, to: &Path) -> io::Result<()> { } Ok(()) } + +/// Clones `names`, entries of the directory `from`, into the directory `to`. +/// +/// `clonefile` takes these in a directory, but not by themselves: FIFOs are +/// made anew, and sockets and devices left out, as nothing is listening on +/// or behind a clone of them. +pub fn clone_entries(from: &Path, names: &[OsString], to: &Path) -> io::Result<()> { + for name in names { + let (from, to) = (from.join(name), to.join(name)); + let meta = from.symlink_metadata()?; + let kind = meta.file_type(); + if kind.is_fifo() { + let to = CString::new(to.as_os_str().as_bytes())?; + if unsafe { libc::mkfifo(to.as_ptr(), meta.mode() as libc::mode_t) } != 0 { + return Err(io::Error::last_os_error()); + } + } else if !(kind.is_socket() || kind.is_block_device() || kind.is_char_device()) { + clone(&from, &to)?; + } + } + Ok(()) +} diff --git a/fsfork/fsfork.rs b/fsfork/fsfork.rs index 1d4b67a..48a095e 100644 --- a/fsfork/fsfork.rs +++ b/fsfork/fsfork.rs @@ -4,6 +4,7 @@ #[cfg(target_os = "macos")] mod darwin; +mod ignored; #[cfg(target_os = "linux")] mod linux; @@ -21,9 +22,20 @@ use std::{ path::{self, Path, PathBuf}, }; +use ignored::Ignored; + +/// What of a directory a fork of it has. +#[derive(Clone, Copy)] +pub enum Contents { + /// Everything in the directory. + Everything, + /// All but what `.gitignore` files ignore, unless it is in the index. + NotIgnored, +} + /// Forks `dir` to the first of `.1`, `.2`, and so on whose fork /// and base are both free, and returns where the fork is. -pub fn fork(dir: &Path) -> io::Result { +pub fn fork(dir: &Path, contents: Contents) -> io::Result { let dir = directory(dir)?; let (Some(parent), Some(name)) = (dir.parent(), dir.file_name()) else { return Err(io::Error::other("cannot fork /")); @@ -36,7 +48,7 @@ pub fn fork(dir: &Path) -> io::Result { continue; } // Another fsfork may take the number first, which the clone finds. - match clone(&dir, &fork) { + match clone(&dir, &fork, contents) { Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue, result => return result.map(|()| fork), } @@ -46,10 +58,10 @@ pub fn fork(dir: &Path) -> io::Result { /// Forks `dir` to `fork`, which must not exist, and returns where the fork /// is, made absolute. -pub fn fork_to(dir: &Path, fork: &Path) -> io::Result { +pub fn fork_to(dir: &Path, fork: &Path, contents: Contents) -> io::Result { let dir = directory(dir)?; let fork = resolve(fork)?; - clone(&dir, &fork)?; + clone(&dir, &fork, contents)?; Ok(fork) } @@ -71,7 +83,7 @@ pub fn base_of(fork: &Path) -> PathBuf { /// Forks `dir` to `fork`: clones it to the fork's base, then the base to the /// fork, so that the two are the same whatever changes in `dir` meanwhile. -fn clone(dir: &Path, fork: &Path) -> io::Result<()> { +fn clone(dir: &Path, fork: &Path, contents: Contents) -> io::Result<()> { if fork.starts_with(dir) { return Err(io::Error::other(format!( "{}: a fork cannot be inside the directory it is a fork of", @@ -79,7 +91,11 @@ fn clone(dir: &Path, fork: &Path) -> io::Result<()> { ))); } let base = base_of(fork); - os::clone(dir, &base).map_err(|e| at(&base, e))?; + match contents { + Contents::Everything => os::clone(dir, &base), + Contents::NotIgnored => clone_not_ignored(dir, &base), + } + .map_err(|e| at(&base, e))?; if let Err(e) = os::clone(&base, fork) { let _ = fs::remove_dir_all(&base); return Err(at(fork, e)); @@ -87,6 +103,46 @@ fn clone(dir: &Path, fork: &Path) -> io::Result<()> { Ok(()) } +/// Clones `dir` to `to`, which must not exist, without what is ignored in it, +/// and leaves nothing if it fails. +fn clone_not_ignored(dir: &Path, to: &Path) -> io::Result<()> { + let ignored = ignored::ignored(dir)?; + if ignored.paths.is_empty() { + return os::clone(dir, to); + } + fs::create_dir(to)?; + let cloned = clone_around(dir, to, &ignored); + if cloned.is_err() { + let _ = fs::remove_dir_all(to); + } + cloned +} + +/// Fills `to`, a new directory, with the clone of `dir`, a directory with +/// something ignored in it: only such directories are gone through, and +/// everything else is cloned whole, at once. +fn clone_around(dir: &Path, to: &Path, ignored: &Ignored) -> io::Result<()> { + let mut whole = Vec::new(); + for entry in fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + if ignored.paths.contains(&path) { + continue; + } + if ignored.around.contains(&path) { + let inside = to.join(entry.file_name()); + fs::create_dir(&inside)?; + clone_around(&path, &inside, ignored)?; + } else { + whole.push(entry.file_name()); + } + } + os::clone_entries(dir, &whole, to)?; + let meta = fs::symlink_metadata(dir)?; + fs::File::open(to)?.set_modified(meta.modified()?)?; + fs::set_permissions(to, meta.permissions()) +} + /// `path`, canonical, if it is a directory. fn directory(path: &Path) -> io::Result { let dir = path.canonicalize().map_err(|e| at(path, e))?; diff --git a/fsfork/ignored.rs b/fsfork/ignored.rs new file mode 100644 index 0000000..ccd0d8f --- /dev/null +++ b/fsfork/ignored.rs @@ -0,0 +1,184 @@ +//! What `.gitignore` files ignore in a directory, as git says. +//! +//! Git never uses the directory's repository, whose configuration could make +//! it run any command, as a command confined to a fork could have changed it, +//! and that fork may be what is forked next. It runs on a repository of its +//! own, made in the temporary directory and removed after, with no +//! configuration but its defaults, and reads only the `.gitignore` files and +//! the index, to keep the files in it, which git does not ignore. + +use std::{ + collections::HashSet, + env, + ffi::OsStr, + fs, io, + os::unix::ffi::OsStrExt, + path::{Path, PathBuf}, + process::{self, Command}, +}; + +/// The paths in a directory that are ignored, and the directories that have +/// some in them. +pub struct Ignored { + pub paths: HashSet, + pub around: HashSet, +} + +/// What is ignored in `dir`, which is canonical. +pub fn ignored(dir: &Path) -> io::Result { + let repo = Repo::new()?; + let (mut root, mut index) = repository(dir); + // A directory that is ignored itself has only its own `.gitignore` files + // to go by, as all of it would be ignored otherwise. + if root != dir && repo.ignores(root, dir)? { + (root, index) = (dir, None); + } + let mut ls_files = repo.git(root); + ls_files + .args(["ls-files", "-z", "--others", "--ignored", "--directory"]) + .arg("--exclude-per-directory=.gitignore") + .current_dir(dir) + .env( + "GIT_INDEX_FILE", + index.unwrap_or_else(|| repo.dir.join("index")), + ); + let listed = run(&mut ls_files)?; + let mut ignored = Ignored { + paths: HashSet::new(), + around: HashSet::new(), + }; + for path in listed.split(|&b| b == 0).filter(|path| !path.is_empty()) { + let path = dir.join(OsStr::from_bytes(path)); + for parent in path.ancestors().skip(1) { + if !parent.starts_with(dir) || !ignored.around.insert(parent.to_path_buf()) { + break; + } + } + ignored.paths.insert(path); + } + Ok(ignored) +} + +/// The top of the repository `dir` is in, or `dir` if it is in none, and +/// where the repository's index is. +fn repository(dir: &Path) -> (&Path, Option) { + for root in dir.ancestors() { + let git = root.join(".git"); + if git.is_dir() { + return (root, Some(git.join("index"))); + } + // A linked worktree or a submodule, whose `.git` names where it is. + if let Some(gitdir) = fs::read(&git).ok().and_then(|file| linked(&file)) { + return (root, Some(root.join(gitdir).join("index"))); + } + } + (dir, None) +} + +/// The directory that a `.git` file holding `file` names. +fn linked(file: &[u8]) -> Option { + let gitdir = file.strip_prefix(b"gitdir: ")?.trim_ascii_end(); + Some(PathBuf::from(OsStr::from_bytes(gitdir))) +} + +/// A repository of `fsfork`'s own, removed when dropped. +struct Repo { + dir: PathBuf, +} + +impl Repo { + /// A new, empty repository in the temporary directory. + fn new() -> io::Result { + let tmp = env::temp_dir(); + let mut n = 0; + let dir = loop { + let dir = tmp.join(format!("fsfork.{}.{n}", process::id())); + match fs::create_dir(&dir) { + Ok(()) => break dir, + Err(e) if e.kind() == io::ErrorKind::AlreadyExists => n += 1, + Err(e) => return Err(e), + } + }; + let repo = Repo { dir }; + run(git().args(["init", "--quiet", "--bare"]).arg(&repo.dir))?; + Ok(repo) + } + + /// A command that runs git on the repository, with `root` for its work + /// tree. + fn git(&self, root: &Path) -> Command { + let mut git = git(); + git.arg("--git-dir") + .arg(&self.dir) + .arg("--work-tree") + .arg(root); + git + } + + /// Whether the `.gitignore` files in `root` ignore `dir`, which is in it. + fn ignores(&self, root: &Path, dir: &Path) -> io::Result { + let checked = self + .git(root) + .args(["check-ignore", "--quiet", "--no-index", "--"]) + .arg(dir) + .current_dir(root) + .output() + .map_err(|e| io::Error::other(format!("cannot run git: {e}")))?; + match checked.status.code() { + Some(0) => Ok(true), + Some(1) => Ok(false), + _ => Err(io::Error::other(format!( + "git: {}", + String::from_utf8_lossy(&checked.stderr).trim() + ))), + } + } +} + +impl Drop for Repo { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.dir); + } +} + +/// A command that runs git with no configuration but its defaults, and none +/// of the variables that change what it does. +fn git() -> Command { + let mut git = Command::new("git"); + for (key, _) in env::vars_os() { + if key.to_string_lossy().starts_with("GIT_") { + git.env_remove(key); + } + } + git.env("GIT_CONFIG_NOSYSTEM", "1") + .env("GIT_CONFIG_GLOBAL", "/dev/null"); + git +} + +/// Runs `command`, and returns what it printed. +fn run(command: &mut Command) -> io::Result> { + let out = command + .output() + .map_err(|e| io::Error::other(format!("cannot run git: {e}")))?; + if !out.status.success() { + return Err(io::Error::other(format!( + "git: {}", + String::from_utf8_lossy(&out.stderr).trim() + ))); + } + Ok(out.stdout) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_git_file_names_the_directory() { + assert_eq!( + linked(b"gitdir: ../.git/worktrees/a\n"), + Some(PathBuf::from("../.git/worktrees/a")) + ); + assert_eq!(linked(b"something else"), None); + } +} diff --git a/fsfork/linux.rs b/fsfork/linux.rs index eb339ac..0ea1daa 100644 --- a/fsfork/linux.rs +++ b/fsfork/linux.rs @@ -1,6 +1,9 @@ //! Linux: a directory is cloned with `cp --reflink`. -use std::{fs, io, path::Path, process::Command}; +use std::{ffi::OsString, fs, io, path::Path, process::Command}; + +/// How many entries one `cp` is given, to keep within the limit on arguments. +const ENTRIES_PER_CP: usize = 256; /// Clones the directory tree at `from` to `to`, which must not exist yet. /// The clone shares its blocks with the original until either is written, on @@ -24,3 +27,22 @@ pub fn clone(from: &Path, to: &Path) -> io::Result<()> { let _ = fs::remove_dir_all(to); Err(failed) } + +/// Clones `names`, entries of the directory `from`, into the directory `to`, +/// as `clone` does. +pub fn clone_entries(from: &Path, names: &[OsString], to: &Path) -> io::Result<()> { + for names in names.chunks(ENTRIES_PER_CP) { + let out = Command::new("cp") + .args(["-a", "--reflink=auto", "--target-directory"]) + .arg(to) + .arg("--") + .args(names.iter().map(|name| from.join(name))) + .output()?; + if !out.status.success() { + return Err(io::Error::other( + String::from_utf8_lossy(&out.stderr).trim().to_string(), + )); + } + } + Ok(()) +} From bf8ba4b07749c47a33a2acd56869606f349af627 Mon Sep 17 00:00:00 2001 From: Pekka Enberg Date: Fri, 30 Oct 2026 09:41:03 +0200 Subject: [PATCH 2/2] Make forks leave out what is ignored by default, with --no-ignore to not A fork of a directory with a large `node_modules` or `target` should be quick without having to ask for it, so what `.gitignore` files ignore is now left out unless `--no-ignore` is given, named as in `rg` and `fd`. `--skip-ignored` is gone. As `fsfork` now needs git by default, it warns and forks everything if git is not there or cannot tell what is ignored: a fork with too much in it works, and refusing to make one would not. --- README.md | 20 +++++------ cmd/confine/confine.1 | 15 ++++----- cmd/confine/confine.rs | 15 ++++----- cmd/confine/tests/confine.rs | 13 +++++--- cmd/fsfork/fsfork.1 | 65 ++++++++++++++++++------------------ cmd/fsfork/fsfork.rs | 10 +++--- cmd/fsfork/tests/fsfork.rs | 47 +++++++++++++++++--------- fsfork/fsfork.rs | 11 ++++-- 8 files changed, 109 insertions(+), 87 deletions(-) diff --git a/README.md b/README.md index 6bc37e2..24d0ad5 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,8 @@ They run on macOS and Linux. - Linux: Linux 5.19 or later, with Landlock enabled, for `confine`. A file system that can share blocks between files, such as Btrfs or XFS, for `fsfork`; on others, a fork is a full copy. -- `git`, for `fsdiff` and `--skip-ignored`. +- `git`, for `fsdiff`, and for `fsfork` to leave out what is ignored; without + it, a fork is everything in the directory. ## fsfork @@ -79,18 +80,17 @@ was forked. The fork is cloned from the base, so that the two are the same whatever changes in the directory meanwhile. With no arguments, it forks the working directory; given one, that directory; given two, to the second. -A fork is everything in the directory, as it is: the repository, -uncommitted changes, and build output, so builds start warm. On macOS, it is +A fork is the directory as it is, the repository and uncommitted changes +included, but for what `.gitignore` files ignore and git does not track, such +as `target` or `node_modules`, so that a large directory is still quick to +fork. Builds in the fork start cold; with `--no-ignore`, for `fsfork` and +`confine`, what is ignored is forked too, and they start warm. On macOS, it is made with `clonefile`, and on Linux with `cp --reflink`, so it takes no space until either side is written to. -`fsfork` knows nothing about git: it forks exactly the directory it is -given. The exception is `--skip-ignored`, for `fsfork` and `confine`, which -leaves out what `.gitignore` files ignore and git does not track, such as -`target` or `node_modules`. The fork is quicker to make if those hold many -files, and builds in it start cold. Git runs in a repository of `fsfork`'s -own, as with `fsdiff`, so the directory's configuration cannot make it run -anything. +`fsfork` asks git what is ignored, in a repository of its own, as `fsdiff` +does, so the directory's configuration cannot make it run anything. If git +cannot say, `fsfork` warns and forks everything. ## fsdiff diff --git a/cmd/confine/confine.1 b/cmd/confine/confine.1 index ffcfe3f..95faac5 100644 --- a/cmd/confine/confine.1 +++ b/cmd/confine/confine.1 @@ -7,7 +7,7 @@ confine \- run a command that may write only to a fork of a directory .IR path ]... .RB [ \-r .IR path ]... -.RB [ \-\-skip\-ignored ] +.RB [ \-\-no\-ignore ] .RB [ \-\-rm | \-d .IR dir ] .RI [ command @@ -76,15 +76,12 @@ It cannot be given with .BR \-d , as there is no fork to remove. .TP -.B \-\-skip\-ignored -Leave out of the fork what +.B \-\-no\-ignore +Fork what .I .gitignore -files ignore and -.BR git (1) -does not track, as -.B fsfork \-\-skip\-ignored -does: see -.BR fsfork (1). +files ignore too, which +.BR fsfork (1) +otherwise leaves out, so that builds in the fork start warm. It cannot be given with .BR \-d , as there is no fork. diff --git a/cmd/confine/confine.rs b/cmd/confine/confine.rs index 041cb6f..87c35a6 100644 --- a/cmd/confine/confine.rs +++ b/cmd/confine/confine.rs @@ -81,10 +81,9 @@ struct Args { #[argh(switch)] rm: bool, - /// leave out of the fork what .gitignore files ignore, unless it is - /// tracked + /// fork what .gitignore files ignore too, which is otherwise left out #[argh(switch)] - skip_ignored: bool, + no_ignore: bool, /// the command to run and its arguments; the shell if none #[argh(positional, greedy)] @@ -105,9 +104,9 @@ fn main() -> ExitCode { eprintln!("confine: --rm removes a fork, and -d runs the command without one"); return ExitCode::FAILURE; } - if args.skip_ignored && args.dir.is_some() { + if args.no_ignore && args.dir.is_some() { eprintln!( - "confine: --skip-ignored leaves things out of a fork, and -d runs the command without one" + "confine: --no-ignore is about what is in a fork, and -d runs the command without one" ); return ExitCode::FAILURE; } @@ -167,10 +166,10 @@ fn confine(args: &Args) -> io::Result { } return run(&command, &dir, &writable, &readable, &hidden, false); } - let contents = if args.skip_ignored { - Contents::NotIgnored - } else { + let contents = if args.no_ignore { Contents::Everything + } else { + Contents::NotIgnored }; let fork = fsfork::fork(Path::new("."), contents)?; eprintln!("confine: forked to {}", fork.display()); diff --git a/cmd/confine/tests/confine.rs b/cmd/confine/tests/confine.rs index 2155e2a..64358bf 100644 --- a/cmd/confine/tests/confine.rs +++ b/cmd/confine/tests/confine.rs @@ -109,27 +109,30 @@ fn removes_the_fork_after_the_command_with_rm() { } #[test] -fn leaves_what_is_ignored_out_of_the_fork_with_skip_ignored() { +fn leaves_what_is_ignored_out_of_the_fork() { need_sandbox!(); let world = World::new(); let dir = world.dir("project"); write(&dir, ".gitignore", "target/\n"); write(&dir, "a", "a\n"); write(&dir, "target/out", "built\n"); - let out = world.confine(&dir, &["--skip-ignored", "sh", "-c", "cat a; ls target"]); + let out = world.confine(&dir, &["sh", "-c", "cat a; ls target"]); assert!(!out.status.success()); assert_eq!(stdout(&out), "a\n"); assert!(!world.dir.join("project.1/target").exists()); assert!(dir.join("target/out").exists()); + let out = world.confine(&dir, &["--no-ignore", "--rm", "cat", "target/out"]); + assert!(out.status.success(), "{}", stderr(&out)); + assert_eq!(stdout(&out), "built\n"); } #[test] -fn skip_ignored_cannot_be_given_with_d() { +fn no_ignore_cannot_be_given_with_d() { let world = World::new(); let dir = world.dir("project"); assert_fails( - &world.confine(&dir, &["--skip-ignored", "-d", ".", "true"]), - "--skip-ignored", + &world.confine(&dir, &["--no-ignore", "-d", ".", "true"]), + "--no-ignore", ); } diff --git a/cmd/fsfork/fsfork.1 b/cmd/fsfork/fsfork.1 index 84902d6..b498a7e 100644 --- a/cmd/fsfork/fsfork.1 +++ b/cmd/fsfork/fsfork.1 @@ -3,7 +3,7 @@ fsfork \- fork a directory: make a copy-on-write clone of it .SH SYNOPSIS .B fsfork -.RB [ \-\-skip\-ignored ] +.RB [ \-\-no\-ignore ] .RI [ dir .RI [ fork ]] .SH DESCRIPTION @@ -15,10 +15,16 @@ It prints the fork's path, and nothing else, so that .B cd $(fsfork) goes there. .PP -The fork is everything in the directory, as it is: hidden files, the git -repository, uncommitted changes, and build output, so builds in the fork -start warm, unless -.B \-\-skip\-ignored +The fork is the directory as it is, hidden files, the git repository, and +uncommitted changes included, but for what +.I .gitignore +files ignore and +.BR git (1) +does not track: build output and dependencies, which can be many files and +so slow to fork, and files such as +.IR .env . +So builds in the fork start cold, unless +.B \-\-no\-ignore is given. Making it is quick, and it takes no space until either it or the directory is written to. @@ -59,35 +65,30 @@ lists them, removes them. Remove a fork's base with it, or its number stays taken. .PP -Without -.BR \-\-skip\-ignored , .B fsfork -knows nothing about git, or what is in the directory: it forks exactly -.IR dir . -In a subdirectory of a repository, it forks the subdirectory. -.SH OPTIONS -.TP -.B \-\-skip\-ignored -Leave out of the fork and its base what -.I .gitignore -files ignore, such as build output and dependencies, and -.BR git (1) -does not track. -Everything else is cloned as without it, the git repository included. -The fork is quicker to make if what is left out holds many files, and its -builds start cold, without files such as -.I .env -if they are ignored. -Only +goes by .I .gitignore -files count, not +files, the ones above +.I dir +in its repository too, but not .I .git/info/exclude or a global excludes file. -It needs -.BR git (1), -which it runs without the repository's configuration, so that nothing in +In a directory that is ignored itself, it goes by the ones in the directory. +It runs +.BR git (1) +without the repository's configuration, so that nothing in .I dir can make it run a command. +If git is not there, or cannot tell what is ignored, +.B fsfork +says so on standard error, and forks everything. +.PP +In a subdirectory of a repository, it forks the subdirectory. +.SH OPTIONS +.TP +.B \-\-no\-ignore +Fork what is ignored too, so that builds in the fork start warm. +Making it takes longer if what is ignored holds many files. .SH EXIT STATUS 0 if the directory was forked, and 1 otherwise, having made neither the fork nor the base. @@ -120,10 +121,10 @@ and remove one: .fi .RE .PP -Fork the working directory, without its build output: +Fork the working directory with its build output, and go there: .PP .RS -.B cd $(fsfork \-\-skip\-ignored) +.B cd $(fsfork \-\-no\-ignore) .RE .SH CAVEATS On macOS, the fork is made with @@ -136,9 +137,7 @@ files, such as Btrfs or XFS; elsewhere, it is a full copy. .PP Hard links in the directory are not hard links in the fork. .PP -With -.BR \-\-skip\-ignored , -a directory with something ignored in it is made anew, with its permissions +A directory with something ignored in it is made anew, with its permissions and modification time, but not its other attributes, and on macOS, sockets and devices beside what is ignored are left out. .PP diff --git a/cmd/fsfork/fsfork.rs b/cmd/fsfork/fsfork.rs index 699bb36..15698f0 100644 --- a/cmd/fsfork/fsfork.rs +++ b/cmd/fsfork/fsfork.rs @@ -15,9 +15,9 @@ use fsfork::Contents; /// Fork a directory: make a copy-on-write clone of it, and print where it is. #[derive(FromArgs)] struct Args { - /// leave out what .gitignore files ignore, unless it is tracked + /// fork what .gitignore files ignore too, which is otherwise left out #[argh(switch)] - skip_ignored: bool, + no_ignore: bool, /// the directory to fork, the working directory if none; then where to /// put the fork, which must not exist, or next to the directory, named @@ -47,10 +47,10 @@ fn main() -> ExitCode { /// Forks the directory `args` give, and returns where the fork is. fn fsfork(args: Args) -> io::Result { - let contents = if args.skip_ignored { - Contents::NotIgnored - } else { + let contents = if args.no_ignore { Contents::Everything + } else { + Contents::NotIgnored }; match &args.paths[..] { [] => fsfork::fork(Path::new("."), contents), diff --git a/cmd/fsfork/tests/fsfork.rs b/cmd/fsfork/tests/fsfork.rs index b11a6d0..f382296 100644 --- a/cmd/fsfork/tests/fsfork.rs +++ b/cmd/fsfork/tests/fsfork.rs @@ -73,16 +73,17 @@ fn forks_to_the_path_given() { } #[test] -fn copies_everything_in_the_directory_as_it_is() { +fn copies_everything_in_the_directory_as_it_is_with_no_ignore() { let world = World::new(); let dir = world.project(); + write(&dir, ".gitignore", "target/\n"); write(&dir, ".hidden", "hidden\n"); write(&dir, ".git/config", "config\n"); write(&dir, "target/out", "built\n"); write(&dir, "run", "#!/bin/sh\n"); fs::set_permissions(dir.join("run"), fs::Permissions::from_mode(0o755)).unwrap(); symlink("a", dir.join("link")).unwrap(); - let fork = PathBuf::from(stdout(&fsfork(&dir, &[])).trim_end()); + let fork = PathBuf::from(stdout(&fsfork(&dir, &["--no-ignore"])).trim_end()); assert_eq!(read(&fork, ".hidden"), "hidden\n"); assert_eq!(read(&fork, ".git/config"), "config\n"); assert_eq!(read(&fork, "target/out"), "built\n"); @@ -92,7 +93,7 @@ fn copies_everything_in_the_directory_as_it_is() { } #[test] -fn leaves_out_what_gitignore_files_ignore_with_skip_ignored() { +fn leaves_out_what_gitignore_files_ignore() { let world = World::new(); let dir = world.project(); write(&dir, ".gitignore", "target/\n*.log\nbuild/*\n!build/keep\n"); @@ -106,7 +107,7 @@ fn leaves_out_what_gitignore_files_ignore_with_skip_ignored() { fs::create_dir(dir.join("empty")).unwrap(); symlink("a", dir.join("kept/link")).unwrap(); fs::set_permissions(dir.join("kept"), fs::Permissions::from_mode(0o750)).unwrap(); - let out = fsfork(&dir, &["--skip-ignored"]); + let out = fsfork(&dir, &[]); assert!(out.status.success(), "{}", stderr(&out)); for tree in [world.path("project.1"), world.path(".project.1.base")] { assert_eq!(read(&tree, "a"), "one\n"); @@ -135,7 +136,7 @@ fn leaves_out_what_gitignore_files_ignore_with_skip_ignored() { } #[test] -fn keeps_tracked_files_and_the_repository_with_skip_ignored() { +fn keeps_tracked_files_and_the_repository() { let world = World::new(); let dir = world.project(); git(&dir, &["init", "--quiet"]); @@ -144,7 +145,7 @@ fn keeps_tracked_files_and_the_repository_with_skip_ignored() { write(&dir, "tracked/other", "ignored\n"); git(&dir, &["add", ".gitignore"]); git(&dir, &["add", "--force", "tracked/forced"]); - let fork = PathBuf::from(stdout(&fsfork(&dir, &["--skip-ignored"])).trim_end()); + let fork = PathBuf::from(stdout(&fsfork(&dir, &[])).trim_end()); assert_eq!(read(&fork, "tracked/forced"), "tracked\n"); assert!(!fork.join("tracked/other").exists()); assert!(!fork.join("a").exists()); @@ -174,7 +175,7 @@ fn skips_what_is_ignored_in_a_subdirectory_and_a_worktree_of_a_repository() { "c", ], ); - let fork = PathBuf::from(stdout(&fsfork(&dir.join("sub"), &["--skip-ignored"])).trim_end()); + let fork = PathBuf::from(stdout(&fsfork(&dir.join("sub"), &[])).trim_end()); assert_eq!(read(&fork, "c"), "c\n"); assert_eq!(read(&fork, "forced.o"), "tracked\n"); assert!(!fork.join("x.o").exists()); @@ -184,13 +185,13 @@ fn skips_what_is_ignored_in_a_subdirectory_and_a_worktree_of_a_repository() { &["worktree", "add", "--quiet", worktree.to_str().unwrap()], ); write(&worktree, "sub/y.o", "ignored\n"); - let fork = PathBuf::from(stdout(&fsfork(&worktree, &["--skip-ignored"])).trim_end()); + let fork = PathBuf::from(stdout(&fsfork(&worktree, &[])).trim_end()); assert_eq!(read(&fork, "sub/forced.o"), "tracked\n"); assert!(!fork.join("sub/y.o").exists()); } #[test] -fn skip_ignored_takes_fifos_and_sockets_beside_what_is_ignored() { +fn takes_fifos_and_sockets_beside_what_is_ignored() { let world = World::new(); let dir = world.project(); write(&dir, ".gitignore", "target/\n"); @@ -206,7 +207,7 @@ fn skip_ignored_takes_fifos_and_sockets_beside_what_is_ignored() { let socket = UnixListener::bind(short.join("sock")); fs::remove_file(&short).unwrap(); let _socket = socket.unwrap(); - let out = fsfork(&dir, &["--skip-ignored"]); + let out = fsfork(&dir, &[]); assert!(out.status.success(), "{}", stderr(&out)); let fork = world.path("project.1"); let pipe = fork.join("pipe").symlink_metadata().unwrap(); @@ -224,13 +225,29 @@ fn a_directory_that_is_ignored_itself_goes_by_its_own_gitignore_files() { write(&dir, "vendor/lib/src", "src\n"); write(&dir, "vendor/lib/out/o", "built\n"); let lib = dir.join("vendor/lib"); - let fork = PathBuf::from(stdout(&fsfork(&lib, &["--skip-ignored"])).trim_end()); + let fork = PathBuf::from(stdout(&fsfork(&lib, &[])).trim_end()); assert_eq!(read(&fork, "src"), "src\n"); assert!(!fork.join("out").exists()); } #[test] -fn skip_ignored_does_not_run_what_the_repository_configures() { +fn forks_everything_and_says_so_if_git_cannot_tell_what_is_ignored() { + let world = World::new(); + let dir = world.project(); + git(&dir, &["init", "--quiet"]); + write(&dir, ".gitignore", "target/\n"); + write(&dir, "target/out", "built\n"); + write(&dir, ".git/index", "not an index\n"); + let out = fsfork(&dir, &[]); + assert!(out.status.success(), "{}", stderr(&out)); + let fork = world.path("project.1"); + assert_eq!(stdout(&out), format!("{}\n", fork.display())); + assert!(stderr(&out).contains("forking everything")); + assert_eq!(read(&fork, "target/out"), "built\n"); +} + +#[test] +fn does_not_run_what_the_repository_configures() { let world = World::new(); let dir = world.project(); git(&dir, &["init", "--quiet"]); @@ -248,21 +265,21 @@ fn skip_ignored_does_not_run_what_the_repository_configures() { &dir, &["config", "core.fsmonitor", monitor.to_str().unwrap()], ); - let out = fsfork(&dir, &["--skip-ignored"]); + let out = fsfork(&dir, &[]); assert!(out.status.success(), "{}", stderr(&out)); assert!(!world.path("project.1/target").exists()); assert!(!ran.exists()); } #[test] -fn fails_with_skip_ignored_if_the_fork_or_its_base_exists() { +fn fails_if_the_fork_or_its_base_exists_with_something_ignored() { let world = World::new(); let dir = world.project(); write(&dir, ".gitignore", "target/\n"); write(&dir, "target/out", "built\n"); write(&world.path("parser"), "mine", "mine\n"); write(&world.path(".lexer.base"), "mine", "mine\n"); - let args = |fork| ["--skip-ignored", "project", fork]; + let args = |fork| ["project", fork]; assert_fails(&fsfork(&world.dir, &args("parser")), "exists"); assert_eq!(read(&world.path("parser"), "mine"), "mine\n"); assert!(!world.path(".parser.base").exists()); diff --git a/fsfork/fsfork.rs b/fsfork/fsfork.rs index 48a095e..c080289 100644 --- a/fsfork/fsfork.rs +++ b/fsfork/fsfork.rs @@ -104,9 +104,16 @@ fn clone(dir: &Path, fork: &Path, contents: Contents) -> io::Result<()> { } /// Clones `dir` to `to`, which must not exist, without what is ignored in it, -/// and leaves nothing if it fails. +/// or with it if that cannot be told, and leaves nothing if it fails. fn clone_not_ignored(dir: &Path, to: &Path) -> io::Result<()> { - let ignored = ignored::ignored(dir)?; + // A fork with too much in it works, so this is no reason not to make one. + let ignored = match ignored::ignored(dir) { + Ok(ignored) => ignored, + Err(e) => { + eprintln!("fsfork: cannot tell what is ignored, so forking everything: {e}"); + return os::clone(dir, to); + } + }; if ignored.paths.is_empty() { return os::clone(dir, to); }