Skip to content

What this does not do

Everything here is a deliberate limit, not a missing feature waiting to be filled in. If you came looking for one of these, the answer is no, and the rest of the section says why and what to do instead.

It only bridges one direction

billy in, afero out. There is no ToBilly, no reverse adapter, and none is planned here. If you need an afero.Fs to look like a billy.Filesystem, that is a different module's job.

The asymmetry is not an oversight. The demand is one-directional: go-git hands out billy filesystems and application code is written against afero, so the traffic all flows the same way. A reverse adapter would have to invent the parts of billy that afero has no answer for — TempFile, Chroot, per-file Lock/Unlock — and would be guessing.

It is not a sandbox

The adapter performs no path validation of its own. It does not clean, resolve or reject anything — .., absolute paths and symlinks that escape the tree are all handed straight to the backend.

Containment, where you get it, comes from the backend:

  • chroot.New(fs, base) and the default osfs.New(dir) implement a soft chroot. billy says plainly that ChrootHelper "is not a security boundary".
  • osfs.New(dir, osfs.WithBoundOS()) enforces the base at the OS boundary and is the one to reach for if containment matters.

Do not treat "I wrapped it in chroot" as protection against hostile paths.

There is no fsync

File.Sync() returns nil without flushing anything, on every backend. billy.File declares no Sync method, so there is nothing in the interface for the adapter to delegate to.

The consequence is worth stating bluntly: a successful Sync() through this adapter is not a durability guarantee. Code that writes a file and calls Sync() before reporting success is, over a disk-backed backend, reporting success on data that may still be in the page cache. If you need durability, do the write through os on the real path.

There is no caching, no layering, no virtual root

The adapter presents a filesystem and nothing else. Every call goes to the backend; there is no read cache, no write buffer, no union or overlay behaviour.

That is not a gap to be filled, because afero already has the pieces and they compose on top:

fs := aferobilly.New(bfs)

ro := afero.NewReadOnlyFs(fs)                  // refuse writes
base := afero.NewBasePathFs(fs, "/subdir")     // a virtual root
cow := afero.NewCopyOnWriteFs(fs, afero.NewMemMapFs())

Building any of that into the adapter would duplicate afero and give you one more thing to configure.

There is no way to reach the billy filesystem underneath

Neither the Fs nor its files expose the wrapped billy.Filesystem or billy.File. There is no Underlying(), no exported struct field, no type assertion that gets you there.

This one is load-bearing rather than incidental. The adapter's concurrency guarantee is that every operation passes through the locker. An accessor would be a supported way to perform an operation that does not, and the guarantee would degrade from "safe by construction" to "safe if everybody remembers". Removing the accessor removes the mistake. Keep your own reference to the billy value if you need one — you had it when you called New.

A locked handle does not make sequences atomic

WithLocker serialises individual operations. It does not, and cannot, know that your read-then-write is meant to be one unit, so another holder of the same lock can act in the gap between them.

There is no transaction API and no WithFS(func(afero.Fs) error) here. The producer that owns the mutex is the right place for a callback that holds the lock across a sequence, because only it knows what a unit of work is. Make a handle concurrency-safe sets out both patterns and why you should pick one per handle.

The locker is not reentrant, and the adapter will not save you

Using the handle inside a critical section that already holds the same locker deadlocks the goroutine against itself. The adapter does not detect this, does not use a reentrant lock, and does not offer a TryLock variant.

A reentrant mutex was the obvious alternative and it is worse. Go's standard library deliberately has none, and simulating one means tracking goroutine identity — which Go does not expose, so it has to be faked. That would trade a deadlock that shows up instantly and reproducibly in development for a subtler failure where a lock is silently re-entered and an invariant the producer relies on quietly breaks. A loud failure at the point of the mistake is the better trade.

Chown and Chtimes will never do anything

They return nil on every backend. go-billy has no interface for either: billy.Change declares Chown, Lchown and Chtimes, and no backend in the library implements it.

Returning an error instead was considered and rejected. Plenty of correct code sets ownership or timestamps as a matter of course after writing a file, and failing those calls would break otherwise-working callers to tell them something that could not have worked anyway. See the reference for what this means per backend.

Note that Chmod is not in this category any more. Since v0.1.2 it delegates to billy.Chmod where the backend implements it, and can return an error.

It does not normalise errors between backends

An error from the backend is returned exactly as it arrived. The same logical failure has a different concrete type on memfs than on osfs, and the adapter does not paper over that.

Normalising would mean inspecting and rewrapping every error, which loses information and puts the adapter in the business of guessing what a backend meant. Matching with errors.Is works across all of them; the error surface lists the cases.

It does not give you a snapshot of a commit

A live worktree view is what this is. If you want the contents of a particular commit or tree in a separate filesystem, copy the tree out with go-git — a view of a checked-out worktree is the wrong tool, and it will show you whatever is checked out rather than what you asked for.