diff --git a/README.md b/README.md index 9e838c5..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`. +- `git`, for `fsdiff`, and for `fsfork` to leave out what is ignored; without + it, a fork is everything in the directory. ## fsfork @@ -79,13 +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. +`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 fda57ce..95faac5 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 [ \-\-no\-ignore ] .RB [ \-\-rm | \-d .IR dir ] .RI [ command @@ -75,6 +76,16 @@ It cannot be given with .BR \-d , as there is no fork to remove. .TP +.B \-\-no\-ignore +Fork what +.I .gitignore +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. +.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..87c35a6 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,10 @@ struct Args { #[argh(switch)] rm: bool, + /// fork what .gitignore files ignore too, which is otherwise left out + #[argh(switch)] + no_ignore: bool, + /// the command to run and its arguments; the shell if none #[argh(positional, greedy)] command: Vec, @@ -99,6 +104,12 @@ fn main() -> ExitCode { eprintln!("confine: --rm removes a fork, and -d runs the command without one"); return ExitCode::FAILURE; } + if args.no_ignore && args.dir.is_some() { + eprintln!( + "confine: --no-ignore is about what is in 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 +166,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.no_ignore { + Contents::Everything + } else { + Contents::NotIgnored + }; + 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..64358bf 100644 --- a/cmd/confine/tests/confine.rs +++ b/cmd/confine/tests/confine.rs @@ -108,6 +108,34 @@ fn removes_the_fork_after_the_command_with_rm() { assert_no_forks(&world); } +#[test] +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, &["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 no_ignore_cannot_be_given_with_d() { + let world = World::new(); + let dir = world.dir("project"); + assert_fails( + &world.confine(&dir, &["--no-ignore", "-d", ".", "true"]), + "--no-ignore", + ); +} + #[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..b498a7e 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 [ \-\-no\-ignore ] .RI [ dir .RI [ fork ]] .SH DESCRIPTION @@ -14,9 +15,17 @@ 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. +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. From then on, the two are separate: what changes in one does not change in @@ -57,9 +66,29 @@ removes them. Remove a fork's base with it, or its number stays taken. .PP .B fsfork -knows nothing about git, or what is in the directory: it forks exactly -.IR dir . +goes by +.I .gitignore +files, the ones above +.I dir +in its repository too, but not +.I .git/info/exclude +or a global excludes file. +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. @@ -91,6 +120,12 @@ and remove one: .B rm \-rf turso.1 .turso.1.base .fi .RE +.PP +Fork the working directory with its build output, and go there: +.PP +.RS +.B cd $(fsfork \-\-no\-ignore) +.RE .SH CAVEATS On macOS, the fork is made with .BR clonefile (2), @@ -102,9 +137,14 @@ 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 +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..15698f0 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 { + /// fork what .gitignore files ignore too, which is otherwise left out + #[argh(switch)] + 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 /// 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.no_ignore { + Contents::Everything + } else { + Contents::NotIgnored + }; 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..f382296 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, }; @@ -70,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"); @@ -88,6 +92,202 @@ 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() { + 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, &[]); + 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() { + 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, &[])).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"), &[])).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, &[])).trim_end()); + assert_eq!(read(&fork, "sub/forced.o"), "tracked\n"); + assert!(!fork.join("sub/y.o").exists()); +} + +#[test] +fn 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, &[]); + 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, &[])).trim_end()); + assert_eq!(read(&fork, "src"), "src\n"); + assert!(!fork.join("out").exists()); +} + +#[test] +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"]); + 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, &[]); + assert!(out.status.success(), "{}", stderr(&out)); + assert!(!world.path("project.1/target").exists()); + assert!(!ran.exists()); +} + +#[test] +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| ["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 +384,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..c080289 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,53 @@ fn clone(dir: &Path, fork: &Path) -> io::Result<()> { Ok(()) } +/// Clones `dir` to `to`, which must not exist, without what is ignored in it, +/// or with it if that cannot be told, and leaves nothing if it fails. +fn clone_not_ignored(dir: &Path, to: &Path) -> io::Result<()> { + // 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); + } + 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(()) +}