Upgrading
View Source0.3 → 0.4: the vfs filesystem
JustBash.new/1, exec/2, exec_file/2, the ~b sigil, :context,
and JustBash.CLI are unchanged. Bash scripts behave as before except
for the exact deltas below.
Exact behavioral changes at the bash level
Every difference a script can observe, verified against the 0.3 sources:
>,>>,&>>,tee, andtee -anow write through symlinks (POSIXO_TRUNC/O_APPENDsemantics): the target file receives the bytes and the link survives; writing through a dangling symlink creates the target. In 0.3, every one of these replaced the link itself with a regular file (>> linkwith target content plus the appended bytes,tee -a linkwith only the appended bytes, dropping the target's content). Writing or appending through a symlink loop now fails with "Too many levels of symbolic links". The same applies to any command that opens its output file (mktemp,curl -o,wget, ...), includingcponto a symlink. Rename-based writers keep their POSIX behavior of replacing the link itself:mv(rename semantics) andsed -i(GNU sed's documented "breaks symbolic links"), both matching bash and 0.3.>>preserves the target's mode. 0.3 reset it to0o644on every append.caton a symlink loop printscat: PATH: Too many levels of symbolic linksand exits 1. 0.3 raised aCaseClauseErrorout ofJustBash.exec/2.mktemp,curl(-o/-D), andwgetreport filesystem write failures with conventional text ("File exists") where 0.3 interpolated the raw error atom ("eexist").[[ a -nt b ]]/-otcompare mtimes chronologically (DateTime.after?/2). 0.3 used structural term comparison, which disagrees with wall-clock order across field boundaries.lnon a mount that doesn't support links fails withln: failed to create ...: Operation not supportedand exit 1 (new situation — mounts didn't exist in 0.3; on the default backend,lnoutput is byte-identical to 0.3, including the directory-hard-link message).Writing through a regular file is now "Not a directory" (exit 1), as POSIX path resolution requires. 0.3 stored the entry anyway —
echo hi > /m/j/2026/a.mdwith/m/ja regular file exited 0, and the file was readable by path but absent fromls, globs,find, andJustBash.FS.walk/3. Every path-creating operation now refuses:>,>>,&>,2>,touch,cp,mv,tee,ln,mkdir,mkdir -p, and anything else that opens an output file.mkdir -pnames the offending ancestor rather than the whole operand, matching GNU coreutils:mkdir -p /m/j/2026reportsmkdir: cannot create directory '/m/j': Not a directory, whilemkdirwithout-pstill names the operand.cpreports a destination under a regular file ascp: cannot stat 'PATH': Not a directory(GNU stats the destination before opening it);cannot create regular fileremains the wording for a merely missing parent.Paths resolve component-by-component on both sides, so a write through a symlinked directory lands on the target directory and is readable back at the path used —
echo hi > /link/a.mdthencat /link/a.mdwith/link -> /real. 0.3 stored an unreachable literal/link/a.mdkey.mkdir -pon a path that already exists as a regular file also fails now (mkdir: cannot create directory 'PATH': File exists, exit 1) instead of reporting success for a directory that does not exist, and so doesmkdir -pon a dangling symlink.One deviation from POSIX remains:
..is collapsed lexically before resolution, soecho hi > /m/j/../k.mdwrites/m/k.mdand exits 0 where bash reportsNot a directory. Real resolution walks..through the directory it lands in; this does not admit unreachable state, so it stayed out of scope.A command whose redirect cannot be opened no longer runs at all. bash opens every redirect target before it forks the command, so
mkdir /made > /m/j/xreportsbash: /m/j/x: Not a directory, exits 1, and leaves no/madebehind — the side effects never happen, not just the output. 0.3 ran the command and applied the redirection to its result, soresult.stdoutstill held"hi\n"forecho hi > /m/j/a.mdand/madewas created. This covers>,>>,2>,&>,&>>, a target that is a directory (:eisdir), and every construct a redirect can attach to: simple commands, functions,for/while/until, subshells, and groups.Because resolution now follows symlinks in every component,
stat/2reports a symlinked directory as a directory — so recursive commands decide descent withlstatinstead, which is what GNU's default-Pdoes.find,du,tree, andgrep -rlist a symlink and stop there rather than walking through it; in 0.3find /dwith/d/self -> /dprinted the subtree once per hop, and two such links never terminated. Consequences worth knowing:find -type fand-type dno longer match symlinks (-type lis now accepted and does),grep -rskips symlinks met while recursing but still follows one named as an operand, andJustBash.FS.walk/3yields a symlink withtype: :symlinkinstead of its target's type. A symlink named directly on the command line is still followed, as it is under-P.Relatedly,
JustBash.new(files: ...),JustBash.FS.new/1, andJustBash.FS.Memory.new/1raiseArgumentErrorfor a map that describes an impossible shape, such as%{"/m/j" => "x", "/m/j/a.md" => "y"}(0.3 accepted it and which entry survived depended on map iteration order) or one that collides with a directory the backend already holds, such as%{"/" => "x"}.A redirect target is created and truncated before the command runs, as
open(2)withO_CREAT | O_TRUNCdoes, so a command can no longer read the file it is redirecting into:cat f > fleavesfempty (0.3 rewrotefwith its own contents), and so does any read-then-overwrite of the same path.>>opens withO_APPENDinstead — an existing target keeps its contents and its mtime, sotrue >> fno longer touchesfat all. A redirect target is also expanded exactly once now:> $(gen-name)runsgen-nameonce, before the command, rather than after it. When several redirections are listed and one cannot be opened, the ones to its left are still created or truncated and the ones to its right are never expanded —echo hi > /bad > $(gen-name)does not rungen-name.With additional mounts only (a 0.4 capability): the parents of a mountpoint appear as synthetic directories, and foreign backends keep their own semantics — e.g. a plain
VFS.Memorymount treats directories implicitly and refusesrmof an empty directory with "Is a directory", where the default backend removes it.
Everything else — every command's output text, exit codes, redirection, globbing, conditionals, heredocs — is covered by the unchanged 3,700-test suite plus the bash-comparison corpus, all passing on both sides of the migration.
What changed is the filesystem underneath bash.fs. It is now a %VFS{}
mount table from the vfs library, with
JustBash.FS.Memory (JustBash's in-memory backend: symlinks, hard links,
modes, mtimes) mounted at /. If your host-side code — custom commands,
test helpers — called JustBash.Fs / JustBash.Fs.InMemoryFs or reached
into bash.fs.data, it needs the mapping below.
Module renames
Yes, the only visible difference in the first row is the case of the s.
0.3 shipped Fs; the project convention (shared with the vfs package —
VFS, never Vfs) fully uppercases acronyms, and the two spellings
cannot coexist as deprecated aliases: Elixir.JustBash.Fs.beam and
Elixir.JustBash.FS.beam are the same file on case-insensitive
filesystems (macOS, Windows). So 0.4 completes the rename in one step,
and mix just_bash.audit flags any survivor — don't proofread for the
case of an s by eye.
| Old (0.3) | New (0.4) |
|---|---|
JustBash.Fs | JustBash.FS |
JustBash.Fs.InMemoryFs | JustBash.FS.Memory (but call through JustBash.FS — bash.fs is a %VFS{}, not a bare backend) |
If you were on 0.3 locally, run mix clean once after updating — a stale
_build can hold both spellings' beams, which case-insensitive
filesystems silently conflate.
Return shapes
Reads now return the updated filesystem as the last element — thread it back into the struct you return, so lazy backends (a git mount fetching blobs on demand) keep their caches:
| Old | New |
|---|---|
{:ok, content} = Fs.read_file(fs, p) | {:ok, content, fs} = FS.read_file(fs, p) |
Fs.exists?(fs, p) #=> boolean | {exists?, fs} = FS.exists?(fs, p) — a tuple; don't use it as an if condition directly |
{:ok, stat} = Fs.stat(fs, p) | {:ok, %VFS.Stat{}, fs} = FS.stat(fs, p) |
{:ok, entries} = Fs.readdir(fs, p) | {:ok, entries, fs} = FS.readdir(fs, p) |
{:ok, target} = Fs.readlink(fs, p) | {:ok, target, fs} = FS.readlink(fs, p) |
{:error, :enoent} | {:error, %VFS.Error{kind: :enoent}} — match on :kind; FS.strerror/1 gives the conventional message text |
Mutations (write_file, mkdir, rm, symlink, link, chmod,
append_file, cp, mv) still return {:ok, fs}.
Two mutation semantics changed on the default backend: FS.write_file/4
and FS.chmod/3 now follow symlinks to the final target, as
append_file/3 always should have — 0.3's Fs.write_file replaced the
link with a regular file, and Fs.chmod set the mode on the link entry
itself. Note that FS.link/3 creates a link-time copy, not a shared
inode: a later write through one name does not update the other (same
as 0.3).
Stat fields
%VFS.Stat{type, size, mtime, mode} replaces the boolean map:
| Old | New |
|---|---|
stat.is_file | stat.type == :regular |
stat.is_directory | stat.type == :directory |
stat.is_symbolic_link | stat.type == :symlink (only ever from lstat) |
stat.mode | stat.mode — now nil on backends without modes; fall back explicitly when displaying |
Option renames
| Old | New |
|---|---|
Fs.mkdir(fs, p, recursive: true) | FS.mkdir(fs, p, parents: true) |
Fs.rm(fs, p, force: true) | removed — match {:error, %VFS.Error{kind: :enoent}} at the call site |
Fs.get_all_paths(fs) | removed — compose from FS.walk(fs, "/", include_dirs: true) |
A migrated custom command
# 0.3
def execute(bash, [path], _stdin) do
resolved = JustBash.Fs.resolve_path(bash.cwd, path)
case JustBash.Fs.read_file(bash.fs, resolved) do
{:ok, content} ->
{:ok, fs} = JustBash.Fs.write_file(bash.fs, resolved, String.upcase(content))
{%{stdout: "", stderr: "", exit_code: 0}, %{bash | fs: fs}}
{:error, :enoent} ->
{%{stdout: "", stderr: "upcase: #{path}: No such file\n", exit_code: 1}, bash}
end
end
# 0.4
def execute(bash, [path], _stdin) do
resolved = JustBash.FS.resolve_path(bash.cwd, path)
case JustBash.FS.read_file(bash.fs, resolved) do
{:ok, content, fs} ->
{:ok, fs} = JustBash.FS.write_file(fs, resolved, String.upcase(content))
{%{stdout: "", stderr: "", exit_code: 0}, %{bash | fs: fs}}
{:error, %VFS.Error{} = err} ->
msg = "upcase: #{path}: #{JustBash.FS.strerror(err)}\n"
{%{stdout: "", stderr: msg, exit_code: 1}, bash}
end
endAuditing your code for silent breakage
Most legacy shapes compile cleanly and misbehave at runtime: a stale
{:ok, content} match silently falls through to your error clause, an
{:error, :enoent} clause silently never matches, and FS.exists?/2 in
an if is a tuple — always truthy. Two lines of defense ship with 0.4:
Static: run the migration auditor over your own code (it scans for
all seven legacy shapes — mix help just_bash.audit has the rule
table):
mix just_bash.audit lib test
# path/file.ex:42 [stale_ok_tuple] matches {:ok, _} on FS.read_file — success is now {:ok, payload, fs}; ...
It exits non-zero on findings, so it can gate CI while you migrate.
Runtime: the two shapes that would otherwise be silently ignored —
FS.mkdir(fs, p, recursive: true) and FS.rm(fs, p, force: true) —
raise ArgumentError with a pointer here instead of doing the wrong
thing quietly.
What you get for the churn
Any VFS.Mountable backend
now mounts into the environment and every bash command sees it:
bash = JustBash.new()
bash = JustBash.mount(bash, "/mnt", VFS.Memory.new(%{"/data.csv" => "a,b\n"}))
{result, bash} = JustBash.exec(bash, "cat /mnt/data.csv")See test/showcase_test.exs for a guided tour.