Upgrading

View Source

0.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:

  1. >, >>, &>>, tee, and tee -a now write through symlinks (POSIX O_TRUNC/O_APPEND semantics): 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 (>> link with target content plus the appended bytes, tee -a link with 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, ...), including cp onto a symlink. Rename-based writers keep their POSIX behavior of replacing the link itself: mv (rename semantics) and sed -i (GNU sed's documented "breaks symbolic links"), both matching bash and 0.3.

  2. >> preserves the target's mode. 0.3 reset it to 0o644 on every append.

  3. cat on a symlink loop prints cat: PATH: Too many levels of symbolic links and exits 1. 0.3 raised a CaseClauseError out of JustBash.exec/2.

  4. mktemp, curl (-o/-D), and wget report filesystem write failures with conventional text ("File exists") where 0.3 interpolated the raw error atom ("eexist").

  5. [[ a -nt b ]] / -ot compare mtimes chronologically (DateTime.after?/2). 0.3 used structural term comparison, which disagrees with wall-clock order across field boundaries.

  6. ln on a mount that doesn't support links fails with ln: failed to create ...: Operation not supported and exit 1 (new situation — mounts didn't exist in 0.3; on the default backend, ln output is byte-identical to 0.3, including the directory-hard-link message).

  7. 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.md with /m/j a regular file exited 0, and the file was readable by path but absent from ls, globs, find, and JustBash.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 -p names the offending ancestor rather than the whole operand, matching GNU coreutils: mkdir -p /m/j/2026 reports mkdir: cannot create directory '/m/j': Not a directory, while mkdir without -p still names the operand. cp reports a destination under a regular file as cp: cannot stat 'PATH': Not a directory (GNU stats the destination before opening it); cannot create regular file remains 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.md then cat /link/a.md with /link -> /real. 0.3 stored an unreachable literal /link/a.md key. mkdir -p on 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 does mkdir -p on a dangling symlink.

    One deviation from POSIX remains: .. is collapsed lexically before resolution, so echo hi > /m/j/../k.md writes /m/k.md and exits 0 where bash reports Not 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/x reports bash: /m/j/x: Not a directory, exits 1, and leaves no /made behind — the side effects never happen, not just the output. 0.3 ran the command and applied the redirection to its result, so result.stdout still held "hi\n" for echo hi > /m/j/a.md and /made was 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/2 reports a symlinked directory as a directory — so recursive commands decide descent with lstat instead, which is what GNU's default -P does. find, du, tree, and grep -r list a symlink and stop there rather than walking through it; in 0.3 find /d with /d/self -> /d printed the subtree once per hop, and two such links never terminated. Consequences worth knowing: find -type f and -type d no longer match symlinks (-type l is now accepted and does), grep -r skips symlinks met while recursing but still follows one named as an operand, and JustBash.FS.walk/3 yields a symlink with type: :symlink instead 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, and JustBash.FS.Memory.new/1 raise ArgumentError for 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"}.

  8. A redirect target is created and truncated before the command runs, as open(2) with O_CREAT | O_TRUNC does, so a command can no longer read the file it is redirecting into: cat f > f leaves f empty (0.3 rewrote f with its own contents), and so does any read-then-overwrite of the same path. >> opens with O_APPEND instead — an existing target keeps its contents and its mtime, so true >> f no longer touches f at all. A redirect target is also expanded exactly once now: > $(gen-name) runs gen-name once, 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 run gen-name.

  9. 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.Memory mount treats directories implicitly and refuses rm of 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.FsJustBash.FS
JustBash.Fs.InMemoryFsJustBash.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:

OldNew
{: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:

OldNew
stat.is_filestat.type == :regular
stat.is_directorystat.type == :directory
stat.is_symbolic_linkstat.type == :symlink (only ever from lstat)
stat.modestat.mode — now nil on backends without modes; fall back explicitly when displaying

Option renames

OldNew
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
end

Auditing 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.