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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions cmd/fsfork/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@ path = "fsfork.rs"
[dependencies]
argh.workspace = true
fsfork.workspace = true
libc.workspace = true
37 changes: 32 additions & 5 deletions cmd/fsfork/fsfork.1
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down
92 changes: 79 additions & 13 deletions cmd/fsfork/fsfork.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -34,23 +40,83 @@ 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
}
}
}

/// 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<PathBuf> {
let contents = if args.no_ignore {
Expand Down
Loading
Loading