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
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,8 @@ rather not trust with your files, work on a directory:

Each does one thing. `confine` forks as `fsfork` does, and `fsdiff` knows
where a fork's base is; otherwise none knows about the others. They keep no
state:
a fork is a directory, its path is its name, and `ls`, `rm` and `git` do
the rest.
state: a fork is a directory, its path is its name, and `ls`, `rm` and
`git` do the rest.

![fsfork forks a repository, rm -rf * in confine empties the fork, and
fsdiff shows the deletions, while the repository keeps its
Expand Down Expand Up @@ -44,7 +43,8 @@ rm -rf ../turso.1 ../.turso.1.base
```

Meanwhile, you can keep working in `~/src/turso`, and run more forks side by
side: `turso.2`, `turso.3`, and so on. `ls -d ../turso.*` lists them.
side: `turso.2`, `turso.3`, and so on, or named ones, `turso.swebench` with
`-n swebench`. `ls -d ../turso.*` lists them.

Each command has a man page: [`fsfork.1`](cmd/fsfork/fsfork.1),
[`confine.1`](cmd/confine/confine.1), and [`fsdiff.1`](cmd/fsdiff/fsdiff.1)
Expand Down Expand Up @@ -78,7 +78,8 @@ They run on macOS and Linux.
and to its base, `.turso.1.base`, which keeps what the directory was when it
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.
working directory; given one, that directory; given two, to the second. With
`-n name`, the fork is `turso.name`, and takes no number.

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
Expand Down
18 changes: 17 additions & 1 deletion cmd/confine/confine.1
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.TH CONFINE 1 2026-10-28 "agentutils 0.1.0" "User Commands"
.TH CONFINE 1 2026-11-01 "agentutils 0.1.0" "User Commands"
.SH NAME
confine \- run a command that may write only to a fork of a directory
.SH SYNOPSIS
Expand All @@ -7,6 +7,8 @@ confine \- run a command that may write only to a fork of a directory
.IR path ]...
.RB [ \-r
.IR path ]...
.RB [ \-n
.IR name ]
.RB [ \-\-no\-ignore ]
.RB [ \-\-rm | \-d
.IR dir ]
Expand All @@ -20,6 +22,9 @@ does, next to it, named after it and the first number not taken
.RB ( turso.1
for
.BR turso ),
or after it and the name given with
.B \-n
.RB ( turso.swebench ),
and runs
.I command
in the fork, or your shell
Expand Down Expand Up @@ -69,6 +74,17 @@ Run the command in
itself, and let it write there, rather than in a fork of the working
directory.
.TP
.BI \-n " name" "\fR, \fP\-\-name " name
Name the fork
.IR <dir>.<name> ,
as
.B fsfork \-n
does, rather than after the first number not taken.
It must not exist.
It cannot be given with
.BR \-d ,
as there is no fork.
.TP
.B \-\-rm
Remove the fork, and its base, when the command exits, as if it had never
been made.
Expand Down
18 changes: 16 additions & 2 deletions cmd/confine/confine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ use linux as os;
compile_error!("confine runs only on macOS and Linux for now");

use std::{
env, io,
env,
ffi::OsStr,
io,
os::unix::process::{CommandExt, ExitStatusExt},
path::{Path, PathBuf},
process::{ExitCode, ExitStatus},
Expand Down Expand Up @@ -77,6 +79,11 @@ struct Args {
#[argh(option, short = 'd')]
dir: Option<PathBuf>,

/// name the fork <dir>.<name> rather than after the first number not
/// taken
#[argh(option, short = 'n', arg_name = "name")]
name: Option<String>,

/// remove the fork when the command exits
#[argh(switch)]
rm: bool,
Expand Down Expand Up @@ -110,6 +117,10 @@ fn main() -> ExitCode {
);
return ExitCode::FAILURE;
}
if args.name.is_some() && args.dir.is_some() {
eprintln!("confine: --name names 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 @@ -171,7 +182,10 @@ fn confine(args: &Args) -> io::Result<Ran> {
} else {
Contents::NotIgnored
};
let fork = fsfork::fork(Path::new("."), contents)?;
let fork = match &args.name {
Some(name) => fsfork::fork_named(Path::new("."), OsStr::new(name), contents)?,
None => 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
30 changes: 30 additions & 0 deletions cmd/confine/tests/confine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,36 @@ fn leaves_what_is_ignored_out_of_the_fork() {
assert_eq!(stdout(&out), "built\n");
}

#[test]
fn names_the_fork_with_name() {
need_sandbox!();
let world = World::new();
let dir = world.dir("project");
write(&dir, "a", "a\n");
let out = world.confine(&dir, &["--name", "swebench", "sh", "-c", "pwd && cat a"]);
assert!(out.status.success(), "{}", stderr(&out));
let fork = world.dir.join("project.swebench");
assert_eq!(stdout(&out), format!("{}\na\n", fork.display()));
assert!(world.dir.join(".project.swebench.base").is_dir());
let out = world.confine(&dir, &["-n", "swebench", "true"]);
assert_fails(&out, "exists");
let out = world.confine(&dir, &["-n", "other", "--rm", "true"]);
assert!(out.status.success(), "{}", stderr(&out));
assert!(!world.dir.join("project.other").exists());
assert!(!world.dir.join(".project.other.base").exists());
}

#[test]
fn name_cannot_be_given_with_d() {
let world = World::new();
let dir = world.dir("project");
assert_fails(
&world.confine(&dir, &["--name", "x", "-d", ".", "true"]),
"--name",
);
assert_no_forks(&world);
}

#[test]
fn no_ignore_cannot_be_given_with_d() {
let world = World::new();
Expand Down
31 changes: 30 additions & 1 deletion cmd/fsfork/fsfork.1
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
.TH FSFORK 1 2026-10-28 "agentutils 0.1.0" "User Commands"
.TH FSFORK 1 2026-11-01 "agentutils 0.1.0" "User Commands"
.SH NAME
fsfork \- fork a directory: make a copy-on-write clone of it
.SH SYNOPSIS
.B fsfork
.RB [ \-\-no\-ignore ]
.RB [ \-n
.IR name ]
.RI [ dir
.RI [ fork ]]
.SH DESCRIPTION
Expand Down Expand Up @@ -43,6 +45,15 @@ makes
then
.IR ~/src/turso.2 ,
and so on.
With
.BR \-n ,
it is named after the directory and
.I name
instead:
.B fsfork \-n swebench
makes
.IR ~/src/turso.swebench ,
which must not exist, and takes no number.
.PP
.B fsfork
also makes the fork's base: another clone of the directory, next to the
Expand Down Expand Up @@ -86,6 +97,14 @@ says so on standard error, and forks everything.
In a subdirectory of a repository, it forks the subdirectory.
.SH OPTIONS
.TP
.BI \-n " name" "\fR, \fP\-\-name " name
Name the fork
.IR <dir>.<name> ,
next to the directory, rather than after the first number not taken.
It cannot be given with
.IR fork ,
which says where the fork goes.
.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.
Expand All @@ -110,6 +129,16 @@ with its base in
.B fsfork turso parser
.RE
.PP
Fork
.I turso
to
.IR turso.swebench ,
and run something there:
.PP
.RS
.B cd $(fsfork \-n swebench turso)
.RE
.PP
List the forks of
.IR turso ,
and remove one:
Expand Down
19 changes: 15 additions & 4 deletions cmd/fsfork/fsfork.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
//! was forked, for `fsdiff` to compare the fork with.

use std::{
ffi::OsStr,
io::{self, Write},
os::unix::ffi::OsStringExt,
path::{Path, PathBuf},
Expand All @@ -19,6 +20,11 @@ struct Args {
#[argh(switch)]
no_ignore: bool,

/// name the fork <dir>.<name>, next to the directory, rather than after
/// the first number not taken
#[argh(option, short = 'n', arg_name = "name")]
name: Option<String>,

/// 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 @@ -52,10 +58,15 @@ fn fsfork(args: Args) -> io::Result<PathBuf> {
} else {
Contents::NotIgnored
};
match &args.paths[..] {
[] => fsfork::fork(Path::new("."), contents),
[dir] => fsfork::fork(dir, contents),
[dir, fork] => fsfork::fork_to(dir, fork, contents),
match (&args.name, &args.paths[..]) {
(None, []) => fsfork::fork(Path::new("."), contents),
(None, [dir]) => fsfork::fork(dir, contents),
(None, [dir, fork]) => fsfork::fork_to(dir, fork, contents),
(Some(name), []) => fsfork::fork_named(Path::new("."), OsStr::new(name), contents),
(Some(name), [dir]) => fsfork::fork_named(dir, OsStr::new(name), contents),
(Some(_), [_, _]) => Err(io::Error::other(
"--name names the fork next to the directory, and a second argument says where to put it; give one or the other",
)),
_ => Err(io::Error::other(
"too many arguments: a directory, and a fork",
)),
Expand Down
41 changes: 41 additions & 0 deletions cmd/fsfork/tests/fsfork.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,47 @@ fn forks_to_the_path_given() {
assert_eq!(read(&world.path(".parser.base"), "a"), "one\n");
}

#[test]
fn names_the_fork_after_the_directory_and_the_name_given() {
let world = World::new();
let dir = world.project();
let out = fsfork(&dir, &["--name", "swebench"]);
assert!(out.status.success(), "{}", stderr(&out));
let fork = world.path("project.swebench");
assert_eq!(stdout(&out), format!("{}\n", fork.display()));
assert_eq!(read(&fork, "a"), "one\n");
assert_eq!(read(&world.path(".project.swebench.base"), "a"), "one\n");
let out = fsfork(&world.dir, &["-n", "lexer", "project"]);
assert_eq!(
stdout(&out),
format!("{}\n", world.path("project.lexer").display())
);
// A named fork takes no number.
let out = fsfork(&dir, &[]);
assert_eq!(
stdout(&out),
format!("{}\n", world.path("project.1").display())
);
}

#[test]
fn fails_if_the_name_is_taken_or_is_no_name() {
let world = World::new();
let dir = world.project();
write(&world.path("project.swebench"), "mine", "mine\n");
assert_fails(&fsfork(&dir, &["--name", "swebench"]), "exists");
assert_eq!(read(&world.path("project.swebench"), "mine"), "mine\n");
assert!(!world.path(".project.swebench.base").exists());
assert_fails(&fsfork(&dir, &["--name", ""]), "empty");
assert_fails(&fsfork(&dir, &["--name", "a/b"]), "/");
assert_fails(
&fsfork(&world.dir, &["--name", "x", "project", "parser"]),
"--name",
);
assert!(!world.path("parser").exists());
assert!(!world.path("project.x").exists());
}

#[test]
fn copies_everything_in_the_directory_as_it_is_with_no_ignore() {
let world = World::new();
Expand Down
39 changes: 32 additions & 7 deletions fsfork/fsfork.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,9 @@ use linux as os;
compile_error!("fsfork runs only on macOS and Linux for now");

use std::{
ffi::OsString,
ffi::{OsStr, OsString},
fs, io,
os::unix::ffi::OsStrExt,
path::{self, Path, PathBuf},
};

Expand All @@ -37,13 +38,8 @@ pub enum Contents {
/// and base are both free, and returns where the fork is.
pub fn fork(dir: &Path, contents: Contents) -> io::Result<PathBuf> {
let dir = directory(dir)?;
let (Some(parent), Some(name)) = (dir.parent(), dir.file_name()) else {
return Err(io::Error::other("cannot fork /"));
};
for n in 1u64.. {
let mut file_name = OsString::from(name);
file_name.push(format!(".{n}"));
let fork = parent.join(file_name);
let fork = next_to(&dir, OsStr::new(&n.to_string()))?;
if fork.symlink_metadata().is_ok() {
continue;
}
Expand All @@ -56,6 +52,24 @@ pub fn fork(dir: &Path, contents: Contents) -> io::Result<PathBuf> {
unreachable!()
}

/// Forks `dir` to `<dir>.<name>` next to it, which must not exist, and
/// returns where the fork is.
pub fn fork_named(dir: &Path, name: &OsStr, contents: Contents) -> io::Result<PathBuf> {
if name.is_empty() {
return Err(io::Error::other("a fork's name cannot be empty"));
}
if name.as_bytes().contains(&b'/') {
return Err(io::Error::other(format!(
"{}: a fork's name cannot have / in it",
name.to_string_lossy()
)));
}
let dir = directory(dir)?;
let fork = next_to(&dir, name)?;
clone(&dir, &fork, contents)?;
Ok(fork)
}

/// Forks `dir` to `fork`, which must not exist, and returns where the fork
/// is, made absolute.
pub fn fork_to(dir: &Path, fork: &Path, contents: Contents) -> io::Result<PathBuf> {
Expand All @@ -81,6 +95,17 @@ pub fn base_of(fork: &Path) -> PathBuf {
fork.with_file_name(name)
}

/// `<dir>.<suffix>`, next to `dir`.
fn next_to(dir: &Path, suffix: &OsStr) -> io::Result<PathBuf> {
let (Some(parent), Some(name)) = (dir.parent(), dir.file_name()) else {
return Err(io::Error::other("cannot fork /"));
};
let mut file_name = OsString::from(name);
file_name.push(".");
file_name.push(suffix);
Ok(parent.join(file_name))
}

/// 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, contents: Contents) -> io::Result<()> {
Expand Down
Loading