// Package ghfs provides a fs.FS implementation backed by remote GitHub repo // contents, fetched via the GitHub API. // // Current limitations / shortcomings: // // - All files have a modified time of 0s since Unix epoch. It may be possible to // fetch last commit that modified the file in question and retrieve the time // the commit was authored to provide an accurate ModTime() value. package ghfs import ( "context" "fmt" "io" "io/fs" "time" "github.com/google/go-github/v47/github" ) // FS is a file system backed by remote GitHub repo contents for a specific // owner/repo at a specific reference provided during instantiation. type FS struct { fetcher // Responsible for actually pulling GH repo contents and downloading } var ( _ fs.FS = &FS{} _ fs.ReadDirFS = &FS{} _ fs.ReadFileFS = &FS{} ) type File struct { rc io.ReadCloser i *info } var _ fs.File = &File{} func (f *File) Stat() (fs.FileInfo, error) { return f.i, nil } func (f *File) Read(b []byte) (int, error) { return f.rc.Read(b) } func (f *File) Close() error { return f.rc.Close() } type Dir struct { i *info files []info offset int } var ( _ fs.File = &Dir{} _ fs.ReadDirFile = &Dir{} ) func (d *Dir) Stat() (fs.FileInfo, error) { return d.i, nil } func (d *Dir) Close() error { return nil } func (d *Dir) Read(_ []byte) (int, error) { return 0, &fs.PathError{Op: "read", Path: d.i.name, Err: fmt.Errorf("is a directory")} } func (d *Dir) ReadDir(count int) ([]fs.DirEntry, error) { n := len(d.files) - d.offset if n == 0 { // Don't return EOF for bogus count if count <= 0 { return nil, nil } // No more files return nil, io.EOF } // If count isn't bigger than the items we have left, use it for n if count > 0 && n > count { n = count } // Create list of DirEntry for total entries we are going to read list := make([]fs.DirEntry, n) for i := range list { list[i] = &d.files[d.offset+i] } // Update offset based on how much we just read d.offset += n return list, nil } // info stores the information about the actual file descriptor in GitHub, it // implements [fs.FileInfo] and [fs.DirEntry] to make implementation of various // fs interfaces easier. type info struct { size int64 mode fs.FileMode name string } var _ fs.FileInfo = &info{} func (i *info) Sys() any { return nil } func (i *info) IsDir() bool { return i.mode == fs.ModeDir } func (i *info) Name() string { return i.name } func (i *info) Size() int64 { return i.size } func (i *info) Mode() fs.FileMode { return i.mode } func (i *info) ModTime() time.Time { return time.Unix(0, 0) } var _ fs.DirEntry = &info{} func (i *info) Type() fs.FileMode { return i.Mode().Type() } func (i *info) Info() (fs.FileInfo, error) { return i, nil } type RefNotFoundError struct { Owner string Repo string Ref string } func (rnfe RefNotFoundError) Error() string { return fmt.Sprintf("the requested ref %s was not found at ", rnfe.Ref) } // New creates a GitHub-backed FS implementation for the specified owner and // repo. // // Refs passed to new that do not exist will result in an error as the request to // open a file on a non-existent ref will always be a 404. // // NOTE: We store the context on the FS because it cannot be passed per-request // and still implement the [fs.FS] interfaces. func New(ctx context.Context, gh *github.Client, owner, repo, ref string) (*FS, error) { _, resp, err := gh.Repositories.GetBranch(ctx, owner, repo, ref, false) if err != nil { if resp.StatusCode == 404 { return nil, RefNotFoundError{Owner: owner, Repo: repo, Ref: ref} } return nil, err } return &FS{fetcher: &ghFetcher{gh, ctx, owner, repo, ref}}, nil } // Open returns a File downloaded from a GitHub repository. // // https://docs.github.com/en/free-pro-team@latest/rest/repos/contents?apiVersion=2022-11-28#get-repository-content func (f *FS) Open(name string) (fs.File, error) { file, dir, err := f.getContents(name) if err != nil { return nil, err } switch { case file != nil: rc, err := f.downloadContents(file) if err != nil { return nil, err } return &File{rc, infoFromRepoContent(file)}, nil case dir != nil: d := &Dir{files: make([]info, 0, len(dir)-1), offset: 0} for _, c := range dir { if c.GetPath() == name { d.i = infoFromRepoContent(c) continue } d.files = append(d.files, *infoFromRepoContent(c)) } return d, nil default: return nil, fmt.Errorf("unexpected state, either file or directory " + "contents should have been returned") } } // ReadDir implements [fs.ReadDirFS] func (f *FS) ReadDir(name string) ([]fs.DirEntry, error) { _, dir, err := f.getContents(name) if err != nil { return nil, err } if dir == nil { return nil, fmt.Errorf("%s is not a directory", name) } entries := make([]fs.DirEntry, 0, len(dir)-1) for _, c := range dir { // Don't include directory itself if c.GetPath() == name { continue } entries = append(entries, infoFromRepoContent(c)) } return entries, nil } // ReadFile implements [fs.ReadFileFS] func (f *FS) ReadFile(name string) ([]byte, error) { file, _, err := f.getContents(name) if err != nil { return nil, err } if file == nil { return nil, fmt.Errorf("unexpected state, file should have been returned") } rc, err := f.downloadContents(file) if err != nil { return nil, err } defer rc.Close() // From go_sdk/src/os/file.go fSize := file.GetSize() fSize++ // one byte for final read at EOF if fSize < 512 { fSize = 512 } data := make([]byte, 0, fSize) for { if len(data) >= cap(data) { d := append(data[:cap(data)], 0) data = d[:len(data)] } n, err := rc.Read(data[len(data):cap(data)]) data = data[:len(data)+n] if err != nil { if err == io.EOF { err = nil } return data, err } } } func infoFromRepoContent(content *github.RepositoryContent) *info { i := &info{ size: int64(content.GetSize()), name: content.GetName(), } switch { case content.GetTarget() != "": i.mode = fs.ModeSymlink case content.GetType() == "dir": i.mode = fs.ModeDir case content.GetType() == "file" || content.GetType() == "submodule": i.mode = fs.FileMode(0) } return i }