Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
a3a2b6e
build: Add go-git integration test
pjbgf May 6, 2026
e795131
osfs: Replace filepath-securejoin with os.Root
pjbgf Oct 22, 2025
0fd9efd
osfs: Fix BoundOS capabilities, error handling and abs path logic
pjbgf Apr 11, 2026
6f22d2d
osfs: Improve BoundOS performance and expand benchmarks
pjbgf Apr 12, 2026
2a33d76
osfs: Extract RootOS from BoundOS for caller-managed os.Root
pjbgf Apr 13, 2026
d768e0f
build: Remove benchmark from wasm
pjbgf Apr 28, 2026
2d7db04
osfs: Avoid leaking os.Root in RootOS.Chroot
pjbgf May 3, 2026
c03000b
osfs: Remove unused tempFile and openFile
pjbgf May 8, 2026
c2029c2
osfs: Normalise path
pjbgf May 8, 2026
83fbcf6
osfs: Check for rooted path
pjbgf May 8, 2026
50c9641
osfs: Ensure basedir does not need to exist
pjbgf May 9, 2026
76b2fb6
osfs: Normalise host path
pjbgf May 9, 2026
56ae016
chroot: Improve handling of Windows paths
pjbgf May 9, 2026
37a480e
osfs: Improve handling of Windows paths
pjbgf May 9, 2026
0bc0ee9
Improve documentation
pjbgf May 9, 2026
978e59d
osfs: Fix build for js/wasm
pjbgf May 9, 2026
9f7f71c
tests: Add RootOS to comparison benchmarks
pjbgf May 9, 2026
4b90a98
Apply go fix ./...
pjbgf May 9, 2026
17bae94
Address review feedback on osfs and chroot helper
pjbgf May 9, 2026
8bf2791
tests: Skip bench comparison in js/wasm
pjbgf May 9, 2026
4cc2e1e
osfs: Remove ChrootOS references
pjbgf May 10, 2026
ca513f9
osfs: Remove unused WithDeduplicatePath option
pjbgf May 10, 2026
64d3e12
osfs: Align capabilities in os_js
pjbgf May 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 114 additions & 0 deletions .github/workflows/go-git-integration.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
on:
pull_request:
workflow_dispatch:

name: Go Git Integration
permissions:
contents: read

jobs:
test:
name: ${{ matrix.os }} / Go stable
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os:
- ubuntu-latest
- macos-latest
- windows-latest
defaults:
run:
shell: bash
steps:
- name: Checkout go-billy
uses: actions/checkout@v4
with:
path: go-billy

- name: Select go-git branch
id: target
run: |
set -e
module_path="$(awk '$1 == "module" { print $2 }' go-billy/go.mod)"

case "${module_path}" in
github.com/go-git/go-billy/v6)
go_git_ref="main"
;;
github.com/go-git/go-billy/v5)
go_git_ref="releases/v5.x"
;;
*)
echo "::error::unsupported go-billy module path: ${module_path}"
exit 1
;;
esac

echo "go_billy_module=${module_path}" >> "${GITHUB_OUTPUT}"
echo "go_git_ref=${go_git_ref}" >> "${GITHUB_OUTPUT}"
echo "Testing ${module_path} against go-git ${go_git_ref}"

- name: Checkout go-git
uses: actions/checkout@v4
with:
repository: go-git/go-git
ref: ${{ steps.target.outputs.go_git_ref }}
path: go-git
fetch-depth: 0

- name: Install Go
Comment thread
pjbgf marked this conversation as resolved.
Dismissed
uses: actions/setup-go@v5
with:
go-version: stable
cache-dependency-path: |
go-billy/go.sum
go-git/**/go.sum

- name: Use local go-billy
run: |
set -e
go_billy_dir="$(cd go-billy && pwd)"
go_git_dir="$(cd go-git && pwd)"
go_billy_module="${{ steps.target.outputs.go_billy_module }}"

if command -v cygpath >/dev/null 2>&1; then
go_billy="$(cygpath -m "${go_billy_dir}")"
go_git="$(cygpath -m "${go_git_dir}")"
else
go_billy="${go_billy_dir}"
go_git="${go_git_dir}"
fi

cd go-git
go_git_module="$(awk '$1 == "module" { print $2 }' go.mod)"
go mod edit -replace "${go_billy_module}=${go_billy}"
go mod tidy

if [ -f cli/go-git/go.mod ]; then
cd cli/go-git
go mod edit -replace "${go_git_module}=${go_git}"
go mod edit -replace "${go_billy_module}=${go_billy}"
go mod tidy
fi

- name: Configure known hosts
continue-on-error: true
if: matrix.os != 'ubuntu-latest'
run: |
mkdir -p ~/.ssh
ssh-keyscan -H github.com > ~/.ssh/known_hosts

- name: Set Git config
run: |
git config --global user.email "gha@example.com"
git config --global user.name "GitHub Actions"

- name: Test go-git
working-directory: go-git
run: go test ./...

- name: Test go-git CLI
if: ${{ hashFiles('go-git/cli/go-git/go.mod') != '' }}
working-directory: go-git/cli/go-git
run: go test ./...
8 changes: 8 additions & 0 deletions embedfs/embed.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,15 @@ import (
"github.com/go-git/go-billy/v6"
)

// Embed is a read-only billy.Filesystem backed by an [embed.FS]. Write
// operations return [billy.ErrReadOnly]; symlink, chroot, lstat and tempfile
// operations return [billy.ErrNotSupported].
type Embed struct {
underlying iofs.FS
}

// New returns a read-only [billy.Filesystem] backed by efs. A nil efs is
// treated as an empty [embed.FS].
func New(efs *embed.FS) billy.Filesystem {
fs := &Embed{
underlying: efs,
Expand All @@ -30,6 +35,9 @@ func New(efs *embed.FS) billy.Filesystem {
return fs
}

// Root returns the empty string. [Embed] has no notion of a base directory:
// paths are passed straight through to the wrapped [embed.FS], which uses
// forward-slash paths rooted at the embedding package.
func (fs *Embed) Root() string {
return ""
}
Expand Down
2 changes: 0 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
module github.com/go-git/go-billy/v6

// go-git supports the last 3 stable Go versions.
go 1.25.0

require (
github.com/cyphar/filepath-securejoin v0.6.1
github.com/stretchr/testify v1.11.1
golang.org/x/sys v0.44.0
)
Expand Down
2 changes: 0 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE=
github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
Expand Down
30 changes: 26 additions & 4 deletions helper/chroot/chroot.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,9 @@ type ChrootHelper struct { //nolint

const maxFollowedSymlinks = 8 // Aligns with POSIX_SYMLOOP_MAX

// New creates a new filesystem wrapping up the given 'fs'.
// The created filesystem has its base in the given ChrootHelperectory of the
// underlying filesystem.
// New creates a new filesystem wrapping the given 'fs', rooted at base.
// All paths passed to the returned filesystem are joined with base before
// being forwarded to the underlying filesystem.
func New(fs billy.Basic, base string) billy.Filesystem {
return &ChrootHelper{
underlying: polyfill.New(fs),
Expand Down Expand Up @@ -358,6 +358,11 @@ func (fs *ChrootHelper) Lstat(filename string) (os.FileInfo, error) {
return sl.Lstat(fullpath)
}

// Symlink creates link with the given target. Slashes in target are
// normalised to the host separator. Absolute targets are rewritten to be
// rooted under the chroot base before being stored, so that the link is
// resolvable when the chroot is reopened from a different working directory.
// Relative targets are stored as provided.
func (fs *ChrootHelper) Symlink(target, link string) error {
target = filepath.FromSlash(target)

Expand All @@ -379,6 +384,15 @@ func (fs *ChrootHelper) Symlink(target, link string) error {
return sl.Symlink(target, link)
}

// Readlink returns the target stored for link.
//
// Relative targets are returned unchanged, preserving the original separators
// as written by [ChrootHelper.Symlink] or the underlying filesystem. Absolute
// targets that resolve under the chroot base are translated back to be
// absolute relative to the chroot (using the host path separator), so callers
// see paths in the chroot's coordinate system rather than the underlying
// filesystem's. Absolute targets that resolve outside the base are returned
// as written.
func (fs *ChrootHelper) Readlink(link string) (string, error) {
fullpath, err := fs.underlyingPath(link)
if err != nil {
Expand All @@ -395,8 +409,16 @@ func (fs *ChrootHelper) Readlink(link string) (string, error) {
return "", err
}

rawTarget := target
target = filepath.FromSlash(target)
if filepath.Separator == '\\' && len(target) >= 3 &&
target[0] == '\\' && target[2] == ':' &&
filepath.VolumeName(fs.base) != "" {
target = target[1:]
}

if !filepath.IsAbs(target) && !strings.HasPrefix(target, string(filepath.Separator)) {
return target, nil
return rawTarget, nil
}

target, err = filepath.Rel(fs.base, target)
Expand Down
71 changes: 69 additions & 2 deletions helper/chroot/chroot_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ func TestOpenFile(t *testing.T) {
assert.Equal(t, f.Name(), filepath.Join("bar", "qux"))

assert.Len(t, m.OpenFileArgs, 1)
assert.Equal(t, m.OpenFileArgs[0], [3]interface{}{"/foo/bar/qux", 42, os.FileMode(0o777)})
assert.Equal(t, m.OpenFileArgs[0], [3]any{"/foo/bar/qux", 42, os.FileMode(0o777)})
}

func TestOpenFileErrCrossedBoundary(t *testing.T) {
Expand Down Expand Up @@ -268,7 +268,7 @@ func TestMkDirAll(t *testing.T) {
require.NoError(t, err)

assert.Len(t, m.MkdirAllArgs, 1)
assert.Equal(t, m.MkdirAllArgs[0], [2]interface{}{"/foo/bar", os.FileMode(0o777)})
assert.Equal(t, m.MkdirAllArgs[0], [2]any{"/foo/bar", os.FileMode(0o777)})
}

func TestMkdirAllErrCrossedBoundary(t *testing.T) {
Expand Down Expand Up @@ -376,6 +376,63 @@ func TestReadlinkWithRelative(t *testing.T) {
assert.Equal(t, m.ReadlinkArgs[0], "/foo/qux/bar")
}

func TestReadlinkPreservesRelativeTarget(t *testing.T) {
m := &readlinkTargetMock{target: "qux/bar"}

fs := New(m, "/foo")
link, err := fs.Readlink("link")
require.NoError(t, err)
assert.Equal(t, "qux/bar", link)
}

func TestReadlinkNormalizesSlashAbsoluteTarget(t *testing.T) {
m := &readlinkTargetMock{target: "/foo/qux/bar"}

fs := New(m, "/foo")
link, err := fs.Readlink("link")
require.NoError(t, err)
assert.Equal(t, string(os.PathSeparator)+filepath.Join("qux", "bar"), link)
}

func TestReadlinkNormalizesSlashPrefixedWindowsDriveTarget(t *testing.T) {
if filepath.Separator != '\\' {
t.Skip("windows-only path form")
}

m := &readlinkTargetMock{target: `/C:/repo/qux/bar`}

fs := New(m, `C:\repo`)
link, err := fs.Readlink("link")
require.NoError(t, err)
assert.Equal(t, `\`+filepath.Join("qux", "bar"), link)
}

func TestReadlinkPreservesDriveLookingTargetUnderWindowsSeparatorRoot(t *testing.T) {
if filepath.Separator != '\\' {
t.Skip("windows-only path form")
}

m := &readlinkTargetMock{target: `\c:\test\123`}

fs := New(m, `\`)
link, err := fs.Readlink("link")
require.NoError(t, err)
assert.Equal(t, `\c:\test\123`, link)
}

func TestReadlinkNormalizesSlashAbsoluteTargetUnderWindowsSeparatorRoot(t *testing.T) {
if filepath.Separator != '\\' {
t.Skip("windows-only path form")
}

m := &readlinkTargetMock{target: `/dir/file`}

fs := New(m, `\`)
link, err := fs.Readlink("link")
require.NoError(t, err)
assert.Equal(t, `\`+filepath.Join("dir", "file"), link)
}

func TestReadlinkErrCrossedBoundary(t *testing.T) {
m := &test.SymlinkMock{}

Expand Down Expand Up @@ -407,3 +464,13 @@ func testCapabilities(t *testing.T, basic billy.Basic) {

assert.Equal(t, capabilities, baseCapabilities)
}

type readlinkTargetMock struct {
test.SymlinkMock
target string
}

func (fs *readlinkTargetMock) Readlink(link string) (string, error) {
fs.ReadlinkArgs = append(fs.ReadlinkArgs, link)
return fs.target, nil
}
8 changes: 4 additions & 4 deletions helper/mount/mount_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ func TestOpenFile(t *testing.T) {

assert.Len(t, underlying.OpenFileArgs, 1)
assert.Equal(t, underlying.OpenFileArgs[0],
[3]interface{}{filepath.Join("bar", "qux"), 42, os.FileMode(0o777)})
[3]any{filepath.Join("bar", "qux"), 42, os.FileMode(0o777)})
assert.Empty(t, source.OpenFileArgs)
}

Expand All @@ -133,7 +133,7 @@ func TestOpenFileInMount(t *testing.T) {
assert.Empty(t, underlying.OpenFileArgs)
assert.Len(t, source.OpenFileArgs, 1)
assert.Equal(t, source.OpenFileArgs[0],
[3]interface{}{filepath.Join("bar", "qux"), 42, os.FileMode(0o777)})
[3]any{filepath.Join("bar", "qux"), 42, os.FileMode(0o777)})
}

func TestStat(t *testing.T) {
Expand Down Expand Up @@ -266,7 +266,7 @@ func TestMkdirAll(t *testing.T) {

assert.Len(t, underlying.MkdirAllArgs, 1)
assert.Equal(t, underlying.MkdirAllArgs[0],
[2]interface{}{filepath.Join("bar", "qux"), os.FileMode(0o777)})
[2]any{filepath.Join("bar", "qux"), os.FileMode(0o777)})
assert.Empty(t, source.MkdirAllArgs)
}

Expand All @@ -278,7 +278,7 @@ func TestMkdirAllInMount(t *testing.T) {
assert.Empty(t, underlying.MkdirAllArgs)
assert.Len(t, source.MkdirAllArgs, 1)
assert.Equal(t, source.MkdirAllArgs[0],
[2]interface{}{filepath.Join("bar", "qux"), os.FileMode(0o777)})
[2]any{filepath.Join("bar", "qux"), os.FileMode(0o777)})
}

func TestLstat(t *testing.T) {
Expand Down
8 changes: 4 additions & 4 deletions internal/test/mock.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ func (l *CallLogger) Log(call string, args string) {
type BasicMock struct {
CreateArgs []string
OpenArgs []string
OpenFileArgs [][3]interface{}
OpenFileArgs [][3]any
StatArgs []string
RenameArgs [][2]string
RemoveArgs []string
Expand All @@ -40,7 +40,7 @@ func (fs *BasicMock) Open(filename string) (billy.File, error) {
}

func (fs *BasicMock) OpenFile(filename string, flag int, mode fs.FileMode) (billy.File, error) {
fs.OpenFileArgs = append(fs.OpenFileArgs, [3]interface{}{filename, flag, mode})
fs.OpenFileArgs = append(fs.OpenFileArgs, [3]any{filename, flag, mode})
return &FileMock{name: filename, callLogger: &fs.CallLogger}, nil
}

Expand Down Expand Up @@ -77,7 +77,7 @@ func (fs *TempFileMock) TempFile(dir, prefix string) (billy.File, error) {
type DirMock struct {
BasicMock
ReadDirArgs []string
MkdirAllArgs [][2]interface{}
MkdirAllArgs [][2]any
}

func (fs *DirMock) ReadDir(path string) ([]fs.DirEntry, error) {
Expand All @@ -86,7 +86,7 @@ func (fs *DirMock) ReadDir(path string) ([]fs.DirEntry, error) {
}

func (fs *DirMock) MkdirAll(filename string, perm fs.FileMode) error {
fs.MkdirAllArgs = append(fs.MkdirAllArgs, [2]interface{}{filename, perm})
fs.MkdirAllArgs = append(fs.MkdirAllArgs, [2]any{filename, perm})
return nil
}

Expand Down
2 changes: 1 addition & 1 deletion memfs/file.go
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ func (fi *fileInfo) IsDir() bool {
return fi.mode.IsDir()
}

func (*fileInfo) Sys() interface{} {
func (*fileInfo) Sys() any {
return nil
}

Expand Down
Loading
Loading