Skip to content

Make fsfork start a shell in the fork on a terminal, with --print to not - #7

Merged
penberg merged 2 commits into
mainfrom
fsfork-shell
Nov 3, 2026
Merged

penberg merged 2 commits into
mainfrom
fsfork-shell

Conversation

@penberg

@penberg penberg commented Oct 30, 2026 •

Copy link
Copy Markdown
Owner

fsfork on a terminal printed a path and left you where you were. A process cannot change its parent's working directory, so now it starts your shell in the fork instead, as confine does with no command:

$ fsfork                # stdin and stdout are terminals
fsfork: forked to /Users/you/src/turso.1
$ exit                  # back in ~/src/turso, and the fork stays

$ cd $(fsfork)          # stdout is captured: prints the path, as before
$ fsfork --print        # always prints the path
  • Stdin has to be a terminal too. Otherwise the shell would run what it reads from there as commands, such as the remaining lines in ... | while read dir; do fsfork "$dir"; done.
  • --print is for scripts. A script run from a terminal is on that terminal too, and would stall in a shell if it does not capture the path.
  • Exit status. On a terminal, it is the shell's. If the shell cannot be run, the fork and base are removed and the status is 1.
  • New tabs open in the fork. Ghostty sets up its shell integration only in the shell it starts, so the shell fsfork starts never reports its directory with OSC 7, and a new tab opened while working in the fork, as in Claude Code, opened in the directory forked. fsfork now reports the fork with OSC 7 before it becomes the shell, and sets PWD to it. The outer shell reports its own directory again once the shell exits.

Rebased on main, so it works with -n from #8.

Not done

demo.tape now uses fsfork --print wordfreq, so the demo's later commands still run from the parent directory, but demo.gif and demo.mp4 are not re-recorded and still show fsfork wordfreq.

confine running a command in a fork has the same problem with new tabs, and is not changed here.

Testing

cargo test and cargo clippy pass on macOS. The new tests run fsfork on a pseudo-terminal with commands typed to a real /bin/sh, covering all three ways of getting the path, the shell's directory, PWD and exit status, the OSC 7 sent to the terminal, and the failure to run a shell. CI covers Linux. The new-tab fix has not been tried in Ghostty itself.

Base automatically changed from fork-skip-ignored to main November 1, 2026 15:40
A process cannot change its parent's working directory, so `fsfork` on a
terminal printed a path and left you where you were, to copy it or to have
known to type `cd $(fsfork)` instead.

Now, when stdin and stdout are terminals, it says where the fork is on
stderr, and becomes your shell in the fork, as `confine` does with no
command; `exit` comes back. When stdout is captured, it prints the path as
before, so `cd $(fsfork)` and `fork=$(fsfork)` work as they did. Stdin has
to be a terminal too, as a shell would otherwise run what it reads from
there as commands, such as the rest of the lines in
`... | while read dir; do fsfork "$dir"; done`.

A script run from a terminal is on that terminal too, and would stall in a
shell if it does not capture the path, so `--print` always prints it. If
the shell cannot be run, the fork is removed, so that failing still leaves
neither the fork nor the base.

The tests run `fsfork` on a pseudo-terminal, with commands typed to a real
`/bin/sh`.
Ghostty sets up its shell integration only in the shell it starts: its
`.zshenv` restores `ZDOTDIR`, so a zsh started inside it, such as the one
`fsfork` becomes, never reports its directory with OSC 7. Ghostty then goes
by what the outer shell said last, the directory forked, and a new tab
opened while working in the fork, as in Claude Code, opens there.

So `fsfork` reports the fork with OSC 7 on the terminal before it becomes
the shell, as a shell does at its prompt. Once the shell exits, the outer
one reports its own directory again. It also sets `PWD` to the fork, which
the shell otherwise inherits as the directory forked.
@penberg
penberg merged commit 04c058c into main Nov 3, 2026
2 checks passed
@penberg
penberg deleted the fsfork-shell branch November 3, 2026 08:32
penberg added a commit that referenced this pull request Nov 3, 2026
Unifies what the three tools print: errors, warnings, status lines and
usage. Each had grown its own shape; now every message is `prog:
subject: problem`, on one line, in lowercase, so that people and agents
can read it, and it is still as short as a UNIX tool's.

| Case | Before | After |
| --- | --- | --- |
| name taken | `fsfork: /private/…/.p.x.base: File exists (os error 17)`
| `fsfork: p.x: file exists` |
| base left over | the same | `fsfork: p.x: its base .p.x.base is left
over; remove it` |
| missing | `fsfork: nope: No such file or directory (os error 2)` |
`fsfork: nope: no such file or directory` |
| not a directory | `fsfork: /private/…/p/a: not a directory` | `fsfork:
p/a: not a directory` |
| not a fork | `fsdiff: /abs/p: no base at /abs/.p.base; is it a fork?`
| `fsdiff: .: not a fork: no .p.base next to it` |
| bad option | `Unrecognized argument: --bogus`, a blank line, `Run
fsfork --help for more information.` | `fsfork: unrecognized argument:
--bogus` and the usage line |
| conflicting options | `confine: --rm removes a fork, and -d runs the
command without one` | `confine: --rm cannot be given with -d, which
makes no fork` and the usage line |
| no such command (macOS) | `sandbox-exec: execvp() of 'nosuchcmd'
failed: …`, exit 71 | `confine: nosuchcmd: command not found`, exit 127
|
| no Landlock (Linux) | `confine: confine needs Landlock, …` | `confine:
Landlock is not available; it needs Linux 5.19 or later` |

- **Capitalization.** The system's error text is lowercased and loses
Rust's `(os error N)`, so that it matches our own text.
- **Paths.** A path is shown as it was typed. A path the command made
itself, such as a fork's, is shown as it would be typed from the working
directory: `p.x`, or `../turso.1`. `forked to` still gives the full
path, because the fork is named only there, and the command then runs
somewhere else.
- **Usage errors** print the man page's synopsis after the problem: the
single most useful line for fixing the arguments.
- **`confine` looks for the command first.** A command that isn't there
now exits 127, as the man page says, and is checked before forking, so
it leaves no fork. A path with a `/` in it is looked for in the
directory the command runs in.
- **The git fallback warning** is printed by the command that forked, so
from `confine` it now says `confine:` and not `fsfork:`. It shows only
the first line of git's error.

The message formatting lives in `fsfork::message`, which all three
commands share. The lib now depends on `argh`, for parsing.

Stacked on #7, since it changes the same `fsfork.rs`. Merge that first.

## Not changed

- `--help` output is still argh's (`Usage:`, `Options:`). It goes to
stdout and isn't a message.
- On Linux, errors from `cp` during a fork are passed through as `cp`
prints them.

## Testing

`cargo test` and `cargo clippy` pass on macOS. New tests check exact
stderr for usage errors in all three commands, for lowercase system
errors with paths as typed, for taken names and left-over bases, and
that `confine` says a missing command is not found, exits 127 and makes
no fork. CI covers Linux. The Landlock message hasn't been seen on a
machine without Landlock.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant