diff --git a/Cargo.lock b/Cargo.lock index 3407825..4ce6fc9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -86,6 +86,7 @@ version = "0.1.0" dependencies = [ "argh", "fsfork", + "libc", ] [[package]] diff --git a/README.md b/README.md index 461f390..99aabb6 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Small UNIX tools for letting a coding agent, or anything else you would rather not trust with your files, work on a directory: - **`fsfork`** forks a directory: it makes a copy-on-write clone of it, and - prints where it is. + starts a shell there, or prints where it is. - **`confine`** runs a command in a fork of the working directory, or in a directory, that may write only there, and cannot read secrets. - **`fsdiff`** shows what changed in a fork since it was made, as a patch. @@ -81,6 +81,12 @@ whatever changes in the directory meanwhile. With no arguments, it forks the working directory; given one, that directory; given two, to the second. With `-n name`, the fork is `turso.name`, and takes no number. +On a terminal, it then starts your shell in the fork, and `exit` comes back. +When stdin or stdout is not a terminal, or with `--print`, it prints the fork's +path instead, so `cd $(fsfork)` goes there in the shell you are in. A script +run from a terminal is on that terminal too, so it should capture the path or +give `--print`. + 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 diff --git a/cmd/fsfork/Cargo.toml b/cmd/fsfork/Cargo.toml index 6213b1e..0ba2c4f 100644 --- a/cmd/fsfork/Cargo.toml +++ b/cmd/fsfork/Cargo.toml @@ -12,3 +12,4 @@ path = "fsfork.rs" [dependencies] argh.workspace = true fsfork.workspace = true +libc.workspace = true diff --git a/cmd/fsfork/fsfork.1 b/cmd/fsfork/fsfork.1 index 626fe4c..732f7c4 100644 --- a/cmd/fsfork/fsfork.1 +++ b/cmd/fsfork/fsfork.1 @@ -4,6 +4,7 @@ fsfork \- fork a directory: make a copy-on-write clone of it .SH SYNOPSIS .B fsfork .RB [ \-\-no\-ignore ] +.RB [ \-\-print ] .RB [ \-n .IR name ] .RI [ dir @@ -13,9 +14,24 @@ fsfork \- fork a directory: make a copy-on-write clone of it makes a copy-on-write clone of .IR dir , or of the working directory if none is given: the fork. -It prints the fork's path, and nothing else, so that +.PP +On a terminal, it says on standard error where the fork is, and becomes your +shell +.RB ( $SHELL , +or +.I /bin/sh +if unset) in the fork; exit the shell to come back. +It tells the terminal that the fork is the working directory, as a shell +does, so that a new tab or window opens there, even if the terminal has +given only the shell it started a way to say so, as Ghostty does. +If standard input or standard output is not a terminal, or with +.BR \-\-print , +it prints the fork's path instead, and nothing else, so that .B cd $(fsfork) goes there. +A script run from a terminal is on that terminal too, so it should capture the +path or give +.BR \-\-print . .PP The fork is the directory as it is, hidden files, the git repository, and uncommitted changes included, but for what @@ -108,11 +124,22 @@ which says where the fork goes. .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. +.TP +.B \-\-print +Print the fork's path, even on a terminal, rather than start a shell there. .SH EXIT STATUS -0 if the directory was forked, and 1 otherwise, having made neither the -fork nor the base. +0 if the directory was forked and its path printed, and 1 otherwise, having +made neither the fork nor the base. +On a terminal, the status of the shell, or 1, having made neither, if the shell +cannot be run. .SH EXAMPLES -Fork the working directory, and go there: +Fork the working directory, and work in the fork in a shell of its own: +.PP +.RS +.B fsfork +.RE +.PP +Fork the working directory, and go there in this shell: .PP .RS .B cd $(fsfork) @@ -126,7 +153,7 @@ with its base in .IR .parser.base : .PP .RS -.B fsfork turso parser +.B fsfork \-\-print turso parser .RE .PP Fork diff --git a/cmd/fsfork/fsfork.rs b/cmd/fsfork/fsfork.rs index a322cca..58b2f57 100644 --- a/cmd/fsfork/fsfork.rs +++ b/cmd/fsfork/fsfork.rs @@ -3,19 +3,25 @@ //! was forked, for `fsdiff` to compare the fork with. use std::{ + env, ffi::OsStr, - io::{self, Write}, - os::unix::ffi::OsStringExt, + io::{self, IsTerminal, Write}, + os::unix::{ffi::OsStringExt, process::CommandExt}, path::{Path, PathBuf}, - process::ExitCode, + process::{Command, ExitCode}, }; use argh::FromArgs; use fsfork::Contents; -/// Fork a directory: make a copy-on-write clone of it, and print where it is. +/// Fork a directory: make a copy-on-write clone of it, and start a shell there +/// or, if not on a terminal, print where it is. #[derive(FromArgs)] struct Args { + /// print where the fork is, rather than start a shell there + #[argh(switch)] + print: bool, + /// fork what .gitignore files ignore too, which is otherwise left out #[argh(switch)] no_ignore: bool, @@ -34,16 +40,19 @@ struct Args { fn main() -> ExitCode { let args: Args = argh::from_env(); - match fsfork(args) { - Ok(fork) => { - let mut line = fork.into_os_string().into_vec(); - line.push(b'\n'); - if let Err(e) = io::stdout().write_all(&line) { - eprintln!("fsfork: {e}"); - return ExitCode::FAILURE; - } - ExitCode::SUCCESS + // A shell needs both: with stdout captured, as in `cd $(fsfork)`, the + // path is wanted, and with stdin not a terminal, a shell would run what + // it reads from there as commands. + let shell = !args.print && io::stdin().is_terminal() && io::stdout().is_terminal(); + let forked = fsfork(args).and_then(|fork| { + if shell { + start_shell(&fork) + } else { + print(fork) } + }); + match forked { + Ok(()) => ExitCode::SUCCESS, Err(e) => { eprintln!("fsfork: {e}"); ExitCode::FAILURE @@ -51,6 +60,63 @@ fn main() -> ExitCode { } } +/// Prints where `fork` is. +fn print(fork: PathBuf) -> io::Result<()> { + let mut line = fork.into_os_string().into_vec(); + line.push(b'\n'); + io::stdout().write_all(&line) +} + +/// Runs the shell in `fork`, in place of this process. If it cannot be run, +/// removes the fork, and returns why. +fn start_shell(fork: &Path) -> io::Result<()> { + eprintln!("fsfork: forked to {}, exit to come back", fork.display()); + let fork = fork.canonicalize().unwrap_or_else(|_| fork.to_path_buf()); + report_cwd(&fork); + let shell = env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string()); + let e = Command::new(&shell) + .current_dir(&fork) + .env("PWD", &fork) + .exec(); + if let Err(e) = fsfork::remove(&fork) { + eprintln!("fsfork: {e}"); + } + Err(io::Error::other(format!("{shell}: {e}"))) +} + +/// Tells the terminal, on stdout, that its working directory is `dir`, with +/// OSC 7, as a shell does at its prompt, so that a new tab or window opens +/// there. The terminal may have given only the shell it started a way to say +/// so, as Ghostty does, so the shell started here may not, and the terminal +/// would otherwise go by what the last one said: the directory forked. The +/// shell `fsfork` was started from says again where it is once this one +/// exits. +fn report_cwd(dir: &Path) { + let mut osc = format!("\x1b]7;file://{}", hostname()).into_bytes(); + for &b in dir.as_os_str().as_encoded_bytes() { + if b.is_ascii_alphanumeric() || b"/-._~".contains(&b) { + osc.push(b); + } else { + osc.extend(format!("%{b:02X}").bytes()); + } + } + osc.push(b'\x07'); + // A terminal that cannot be told is no reason not to start the shell. + let mut stdout = io::stdout(); + let _ = stdout.write_all(&osc).and_then(|()| stdout.flush()); +} + +/// The host's name, or none if it has none to give. +fn hostname() -> String { + let mut name = [0u8; 256]; + let got = unsafe { libc::gethostname(name.as_mut_ptr().cast(), name.len()) }; + if got != 0 { + return String::new(); + } + let len = name.iter().position(|&b| b == 0).unwrap_or(name.len()); + String::from_utf8_lossy(&name[..len]).into_owned() +} + /// Forks the directory `args` give, and returns where the fork is. fn fsfork(args: Args) -> io::Result { let contents = if args.no_ignore { diff --git a/cmd/fsfork/tests/fsfork.rs b/cmd/fsfork/tests/fsfork.rs index 7ba5f4f..a52ab33 100644 --- a/cmd/fsfork/tests/fsfork.rs +++ b/cmd/fsfork/tests/fsfork.rs @@ -2,16 +2,134 @@ //! `fsfork.1` says it does. use std::{ - env, fs, - os::unix::{ - fs::{FileTypeExt, PermissionsExt, symlink}, - net::UnixListener, + env, + fs::{self, File}, + io::{Read, Write}, + os::{ + fd::{FromRawFd, OwnedFd}, + unix::{ + fs::{FileTypeExt, PermissionsExt, symlink}, + net::UnixListener, + }, }, path::{Path, PathBuf}, - process::{self, Command, Output}, - thread, + process::{self, Command, Output, Stdio}, + ptr, thread, }; +#[test] +fn on_a_terminal_starts_a_shell_in_the_fork() { + let world = World::new(); + let dir = world.project(); + let fork = world.path("project.1"); + let pwd = world.path("pwd"); + + let typed = format!( + "pwd -P > {0}; echo \"$PWD\" >> {0}; exit 7\n", + pwd.display() + ); + let on = Terminal { + stdin: true, + stdout: true, + }; + let (out, screen) = fsfork_on_terminal(&dir, &[], on, "/bin/sh", &typed); + + assert_eq!(out.status.code(), Some(7), "{}", stderr(&out)); + assert_eq!( + read(&world.dir, "pwd"), + format!("{0}\n{0}\n", fork.display()) + ); + assert!( + stderr(&out).contains(&format!("forked to {},", fork.display())), + "{}", + stderr(&out) + ); + // It tells the terminal the fork is the working directory, so that a new + // tab opens there, and prints its path nowhere else on the terminal. + let osc = format!("\x1b]7;file://{}{}\x07", hostname(), fork.display()); + assert!(screen.contains(&osc), "{screen:?}"); + assert!( + !screen.replacen(&osc, "", 1).contains("project.1"), + "{screen}" + ); + assert_eq!(read(&fork, "a"), "one\n"); + assert!(world.path(".project.1.base").is_dir()); +} + +#[test] +fn prints_the_path_when_stdout_is_captured() { + let world = World::new(); + let dir = world.project(); + let ran = world.path("ran"); + + let typed = format!("touch {}\nexit\n", ran.display()); + let on = Terminal { + stdin: true, + stdout: false, + }; + let (out, _) = fsfork_on_terminal(&dir, &[], on, "/bin/sh", &typed); + + assert!(out.status.success(), "{}", stderr(&out)); + assert_eq!( + stdout(&out), + format!("{}\n", world.path("project.1").display()) + ); + assert!(!ran.exists()); +} + +#[test] +fn prints_the_path_rather_than_run_what_stdin_holds() { + let world = World::new(); + let dir = world.project(); + let ran = world.path("ran"); + + let typed = format!("touch {}\nexit\n", ran.display()); + let on = Terminal { + stdin: false, + stdout: true, + }; + let (out, screen) = fsfork_on_terminal(&dir, &[], on, "/bin/sh", &typed); + + assert!(out.status.success(), "{}", stderr(&out)); + assert!(screen.contains("project.1"), "{screen}"); + assert!(!ran.exists()); +} + +#[test] +fn print_prints_the_path_on_a_terminal() { + let world = World::new(); + let dir = world.project(); + let ran = world.path("ran"); + + let typed = format!("touch {}\nexit\n", ran.display()); + let on = Terminal { + stdin: true, + stdout: true, + }; + let (out, screen) = fsfork_on_terminal(&dir, &["--print"], on, "/bin/sh", &typed); + + assert!(out.status.success(), "{}", stderr(&out)); + assert!(screen.contains("project.1"), "{screen}"); + assert!(world.path("project.1").is_dir()); + assert!(!ran.exists()); +} + +#[test] +fn removes_the_fork_if_the_shell_cannot_be_run() { + let world = World::new(); + let dir = world.project(); + + let on = Terminal { + stdin: true, + stdout: true, + }; + let (out, _) = fsfork_on_terminal(&dir, &[], on, "/nonexistent/shell", ""); + + assert_fails(&out, "/nonexistent/shell"); + assert!(!world.path("project.1").exists()); + assert!(!world.path(".project.1.base").exists()); +} + #[test] fn forks_the_working_directory_next_to_it() { let world = World::new(); @@ -425,6 +543,95 @@ fn fsfork(cwd: &Path, args: &[&str]) -> Output { .unwrap() } +/// Which of `fsfork`'s stdin and stdout are a terminal. +#[derive(Clone, Copy)] +struct Terminal { + stdin: bool, + stdout: bool, +} + +/// Runs `fsfork` with `args` in `cwd`, with `Terminal` saying what is on a +/// terminal, `shell` as `$SHELL`, and `typed` to read on stdin. Returns how it +/// went, and what came out on the terminal, which has `typed` in it, echoed. +fn fsfork_on_terminal( + cwd: &Path, + args: &[&str], + on: Terminal, + shell: &str, + typed: &str, +) -> (Output, String) { + let (controller, tty) = pty(); + let mut controller = File::from(controller); + let mut reader = controller.try_clone().unwrap(); + let terminal = || File::from(tty.try_clone().unwrap()); + let mut command = Command::new(env!("CARGO_BIN_EXE_fsfork")); + command + .args(args) + .current_dir(cwd) + .env("SHELL", shell) + .stderr(Stdio::piped()); + if on.stdin { + command.stdin(terminal()); + controller.write_all(typed.as_bytes()).unwrap(); + } else { + command.stdin(Stdio::piped()); + } + if on.stdout { + command.stdout(terminal()); + } else { + command.stdout(Stdio::piped()); + } + let mut child = command.spawn().unwrap(); + // Only the command may hold the terminal open, for reading it to end. + drop(command); + drop(tty); + if let Some(mut stdin) = child.stdin.take() { + // It may be gone already, having no use for stdin. + let _ = stdin.write_all(typed.as_bytes()); + } + // Read as it runs, as a terminal may drop what is left when closed. It + // ends in an error rather than at the end of the file on Linux. + let screen = thread::spawn(move || { + let mut screen = Vec::new(); + let _ = reader.read_to_end(&mut screen); + String::from_utf8_lossy(&screen).into_owned() + }); + let out = child.wait_with_output().unwrap(); + (out, screen.join().unwrap()) +} + +/// The host's name, as `fsfork` tells the terminal it. +fn hostname() -> String { + let mut name = [0u8; 256]; + let got = unsafe { libc::gethostname(name.as_mut_ptr().cast(), name.len()) }; + assert_eq!(got, 0, "gethostname failed"); + let len = name.iter().position(|&b| b == 0).unwrap(); + String::from_utf8(name[..len].to_vec()).unwrap() +} + +/// A new pseudo-terminal's two ends. +fn pty() -> (OwnedFd, OwnedFd) { + let (mut controller, mut tty) = (-1, -1); + let opened = unsafe { + libc::openpty( + &mut controller, + &mut tty, + ptr::null_mut(), + ptr::null_mut(), + ptr::null_mut(), + ) + }; + assert_eq!(opened, 0, "openpty failed"); + for fd in [controller, tty] { + // So that no other test's command holds the terminal open. + assert_eq!( + unsafe { libc::fcntl(fd, libc::F_SETFD, libc::FD_CLOEXEC) }, + 0 + ); + } + unsafe { (OwnedFd::from_raw_fd(controller), OwnedFd::from_raw_fd(tty)) } +} + /// Runs git with `args` in `dir`, which must succeed. fn git(dir: &Path, args: &[&str]) { let out = Command::new("git") diff --git a/demo/demo.tape b/demo/demo.tape index fc0210a..dc7164c 100644 --- a/demo/demo.tape +++ b/demo/demo.tape @@ -26,7 +26,7 @@ Type "export PS1='%F{magenta}%1~%f $ ' && clear" Enter Sleep 1s Show -Type "fsfork wordfreq" Sleep 300ms Enter +Type "fsfork --print wordfreq" Sleep 300ms Enter Sleep 1s Type "confine -d wordfreq.1" Sleep 300ms Enter Sleep 1s