From 3c063ed9cec9c2aa927161ab0352363da51bed5d Mon Sep 17 00:00:00 2001 From: Pekka Enberg Date: Sun, 1 Nov 2026 16:49:25 +0200 Subject: [PATCH] Make fsfork and confine name a fork with -n, after the directory A fork is otherwise named after the directory and the first number not taken, turso.1, which says nothing about what it is for. With -n swebench, it is turso.swebench, and its base .turso.swebench.base, so that forks made for different things can be told apart, and a script can know where its fork is without parsing fsfork's output. A named fork takes no number. The name cannot be empty or have / in it, as the fork goes next to the directory, and cannot be given with a second argument, which says where the fork goes instead. In confine, it cannot be given with -d, which runs the command without a fork. --- README.md | 11 +++++----- cmd/confine/confine.1 | 18 +++++++++++++++- cmd/confine/confine.rs | 18 ++++++++++++++-- cmd/confine/tests/confine.rs | 30 ++++++++++++++++++++++++++ cmd/fsfork/fsfork.1 | 31 ++++++++++++++++++++++++++- cmd/fsfork/fsfork.rs | 19 +++++++++++++---- cmd/fsfork/tests/fsfork.rs | 41 ++++++++++++++++++++++++++++++++++++ fsfork/fsfork.rs | 39 ++++++++++++++++++++++++++++------ 8 files changed, 187 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 24d0ad5..461f390 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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) @@ -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 diff --git a/cmd/confine/confine.1 b/cmd/confine/confine.1 index 95faac5..4f78420 100644 --- a/cmd/confine/confine.1 +++ b/cmd/confine/confine.1 @@ -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 @@ -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 ] @@ -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 @@ -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 . , +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. diff --git a/cmd/confine/confine.rs b/cmd/confine/confine.rs index 87c35a6..55440cf 100644 --- a/cmd/confine/confine.rs +++ b/cmd/confine/confine.rs @@ -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}, @@ -77,6 +79,11 @@ struct Args { #[argh(option, short = 'd')] dir: Option, + /// name the fork . rather than after the first number not + /// taken + #[argh(option, short = 'n', arg_name = "name")] + name: Option, + /// remove the fork when the command exits #[argh(switch)] rm: bool, @@ -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, @@ -171,7 +182,10 @@ fn confine(args: &Args) -> io::Result { } 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(_)))) diff --git a/cmd/confine/tests/confine.rs b/cmd/confine/tests/confine.rs index 64358bf..b24365e 100644 --- a/cmd/confine/tests/confine.rs +++ b/cmd/confine/tests/confine.rs @@ -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(); diff --git a/cmd/fsfork/fsfork.1 b/cmd/fsfork/fsfork.1 index b498a7e..626fe4c 100644 --- a/cmd/fsfork/fsfork.1 +++ b/cmd/fsfork/fsfork.1 @@ -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 @@ -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 @@ -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 . , +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. @@ -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: diff --git a/cmd/fsfork/fsfork.rs b/cmd/fsfork/fsfork.rs index 15698f0..a322cca 100644 --- a/cmd/fsfork/fsfork.rs +++ b/cmd/fsfork/fsfork.rs @@ -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}, @@ -19,6 +20,11 @@ struct Args { #[argh(switch)] no_ignore: bool, + /// name the fork ., next to the directory, rather than after + /// the first number not taken + #[argh(option, short = 'n', arg_name = "name")] + name: Option, + /// 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 @@ -52,10 +58,15 @@ fn fsfork(args: Args) -> io::Result { } 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", )), diff --git a/cmd/fsfork/tests/fsfork.rs b/cmd/fsfork/tests/fsfork.rs index f382296..7ba5f4f 100644 --- a/cmd/fsfork/tests/fsfork.rs +++ b/cmd/fsfork/tests/fsfork.rs @@ -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(); diff --git a/fsfork/fsfork.rs b/fsfork/fsfork.rs index c080289..32bff75 100644 --- a/fsfork/fsfork.rs +++ b/fsfork/fsfork.rs @@ -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}, }; @@ -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 { 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; } @@ -56,6 +52,24 @@ pub fn fork(dir: &Path, contents: Contents) -> io::Result { unreachable!() } +/// Forks `dir` to `.` 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 { + 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 { @@ -81,6 +95,17 @@ pub fn base_of(fork: &Path) -> PathBuf { fork.with_file_name(name) } +/// `.`, next to `dir`. +fn next_to(dir: &Path, suffix: &OsStr) -> io::Result { + 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<()> {