Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 10 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down
11 changes: 11 additions & 0 deletions cmd/confine/confine.1
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
18 changes: 17 additions & 1 deletion cmd/confine/confine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<String>,
Expand All @@ -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,
Expand Down Expand Up @@ -155,7 +166,12 @@ fn confine(args: &Args) -> io::Result<Ran> {
}
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(_))))
Expand Down
28 changes: 28 additions & 0 deletions cmd/confine/tests/confine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
50 changes: 45 additions & 5 deletions cmd/fsfork/fsfork.1
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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),
Expand All @@ -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)
16 changes: 13 additions & 3 deletions cmd/fsfork/fsfork.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -42,10 +47,15 @@ fn main() -> ExitCode {

/// Forks the directory `args` give, and returns where the fork is.
fn fsfork(args: Args) -> io::Result<PathBuf> {
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",
)),
Expand Down
Loading
Loading