Skip to content

billy backend support

The adapter delegates to whatever billy.Filesystem you hand it, so several answers to "does this work?" are really answers about the backend. This page states what each of the common backends honours, and which behaviour is the adapter's and which is billy's.

Facts on this page were checked against go-billy v5.9.1, the version this module requires. A different billy release may change them.

The capability matrix

memfs.New() osfs.New(dir) osfs.New(dir, osfs.WithBoundOS())
Concrete type *chroot.ChrootHelper *chroot.ChrootHelper *osfs.BoundOS
Name() returns / dir dir
Implements billy.Chmod yes yes yes
Implements billy.Change no no no
Chmod observable in memory on disk on disk
File mode on create recorded on disk on disk
Directory mode from Mkdir honoured ignored, always 0755 honoured
Symlinks yes yes relative targets only
Files satisfy io.WriterAt no no yes
File.Sync() flushes no no no — the adapter never calls it

memfs.New() and osfs.New(dir) both wrap themselves in billy's chroot helper, which is why they share a concrete type and several of these answers.

Which backend should you use?

  • memfs.New() for tests and for anything that must not touch disk. It is what go-git gives you for an in-memory clone.
  • osfs.New(dir) for a local git worktree. This is what go-git uses for a disk-backed clone, so it is the backend the flagship use case actually runs on.
  • osfs.New(dir, osfs.WithBoundOS()) when you want the base directory enforced at the OS boundary. billy marks the default ChrootOS as deprecated in favour of it, but the behaviour differs in ways listed below, so it is not a drop-in swap.
  • chroot.New(fs, base) to narrow any of the above to a subtree, then wrap the result.

Nothing here is a security boundary — see what this does not do.

Are file permissions honoured?

Yes on all three, both at create time and through Chmod.

fs := aferobilly.New(osfs.New(dir))
_ = afero.WriteFile(fs, "script.sh", []byte("#!/bin/sh\n"), 0o644)
_ = fs.Chmod("script.sh", 0o755)   // observable as 0755 on disk

This matters for git: go-git derives a committed blob's mode (100755 vs 100644) from the worktree file's mode, so a scaffolder that writes a script through the adapter and chmods it executable produces an executable blob. Before v0.1.2 Chmod was an unconditional no-op and it did not.

memfs records the mode and reports it back through Stat, but nothing enforces it — there is no inode and no kernel involved.

Are directory permissions honoured?

Not on osfs.New(dir). billy's ChrootOS.MkdirAll ignores the perm argument and calls os.MkdirAll(path, 0755) unconditionally:

func (fs *ChrootOS) MkdirAll(path string, perm os.FileMode) error {
    return os.MkdirAll(path, defaultDirectoryMode)   // defaultDirectoryMode = 0o755
}

So fs.MkdirAll("/private", 0o700) produces a 0755 directory on disk. memfs and osfs with WithBoundOS() both honour the mode you pass.

If you need a restricted directory on the default osfs, create it and then Chmod it — Chmod is delegated and does reach disk.

Do Chown and Chtimes work anywhere?

No, on any backend, ever. Both return nil without doing anything. go-billy exposes no interface for either operation: billy.Change — which adds Chown, Lchown and Chtimes on top of Chmod — is declared but implemented by no backend in the library, so there would be nothing to delegate to even if the adapter tried.

If ownership or timestamps matter to your code, this adapter cannot carry them. Operate on the real path with os directly.

memfs and osfs.New(dir) handle both relative and absolute targets.

WithBoundOS() rejects absolute targets. Creating the link succeeds, and reading it back fails:

fs := aferobilly.New(osfs.New(dir, osfs.WithBoundOS()))
sl := fs.(afero.Symlinker)

_ = sl.SymlinkIfPossible("/t", "/l")     // succeeds
_, err := sl.ReadlinkIfPossible("/l")    // "/l": path outside base dir "…": file does not exist

_ = sl.SymlinkIfPossible("t", "l")       // relative target
got, err := sl.ReadlinkIfPossible("l")   // "t", nil

The same absolute pair works under osfs.New(dir). Use relative targets if you might switch backends.

Why is WriteAt emulated on memfs and osfs?

Because billy's chroot helper wraps every file it returns in a struct that embeds the billy.File interface, not the concrete file. Embedding an interface promotes only that interface's methods, and billy.File deliberately omits io.WriterAt — upstream has it commented out with // TODO: Add io.WriterAt for v6.

memfs.New() and osfs.New(dir) both go through chroot, so their files never satisfy io.WriterAt from the outside even though an *os.File sits underneath. The adapter falls back to seek, write, seek back. osfs.New(dir, osfs.WithBoundOS()) returns an unwrapped *osfs.file, which does satisfy it.

The observable result is identical; only the number of backend calls differs. Details on the fallback are in filesystem operations.

What if the backend does not implement a capability?

A billy.Filesystem assembled from a billy.Basic — for instance via chroot.New(basic, "/") — goes through billy's polyfill helper, which answers any missing capability with billy.ErrNotSupported ("feature not supported"). That error travels back through the adapter unchanged:

errors.Is(err, billy.ErrNotSupported)   // true

MkdirAll, ReadDir, TempFile, Symlink, Lstat, Readlink and Chmod can all return it. Chmod is the one exception where the adapter has its own fallback: if the value you passed to New does not itself satisfy billy.Chmod, the adapter returns nil and does nothing rather than delegating. A chroot-wrapped filesystem always satisfies billy.Chmod, so in that case you get ErrNotSupported rather than the silent no-op.

Does go-git's worktree filesystem work?

Yes, and it is the case the module exists for. repo.Worktree().Filesystem is a billy filesystem — the package documentation names osfs as what a local clone gives you, and an in-memory clone gives you memfs. Both are covered above. The adapter never imports go-git; a dependency guard test keeps it out of the graph. See work with a go-git worktree.