diff --git a/git/repo.go b/git/repo.go index b6d95b56d75c1cd6f18e6224b12d8223ff79daae..9dd674032cdab567f48a64092386e78f5ac327c0 100644 --- a/git/repo.go +++ b/git/repo.go @@ -2,6 +2,7 @@ package git import ( "path/filepath" + "strings" "github.com/gogs/git-module" ) @@ -29,6 +30,10 @@ func Clone(src, dst string, opts ...git.CloneOptions) error { // Init initializes and opens a new git repository. func Init(path string, bare bool) (*Repository, error) { + if bare { + path = strings.TrimSuffix(path, ".git") + ".git" + } + err := git.Init(path, git.InitOptions{Bare: bare}) if err != nil { return nil, err diff --git a/server/backend/access.go b/server/backend/access.go new file mode 100644 index 0000000000000000000000000000000000000000..52cc16eff8357c46fa84d18bed37336ba81ded82 --- /dev/null +++ b/server/backend/access.go @@ -0,0 +1,42 @@ +package backend + +import "golang.org/x/crypto/ssh" + +// AccessLevel is the level of access allowed to a repo. +type AccessLevel int + +const ( + // NoAccess does not allow access to the repo. + NoAccess AccessLevel = iota + + // ReadOnlyAccess allows read-only access to the repo. + ReadOnlyAccess + + // ReadWriteAccess allows read and write access to the repo. + ReadWriteAccess + + // AdminAccess allows read, write, and admin access to the repo. + AdminAccess +) + +// String returns the string representation of the access level. +func (a AccessLevel) String() string { + switch a { + case NoAccess: + return "no-access" + case ReadOnlyAccess: + return "read-only" + case ReadWriteAccess: + return "read-write" + case AdminAccess: + return "admin-access" + default: + return "unknown" + } +} + +// AccessMethod is an interface that handles repository authorization. +type AccessMethod interface { + // AccessLevel returns the access level for the given repository and key. + AccessLevel(repo string, pk ssh.PublicKey) AccessLevel +} diff --git a/server/backend/backend.go b/server/backend/backend.go new file mode 100644 index 0000000000000000000000000000000000000000..0bc7d6f62fe26e5bfb9c38e3a024bed57cff0a25 --- /dev/null +++ b/server/backend/backend.go @@ -0,0 +1,84 @@ +package backend + +import ( + "bytes" + + "golang.org/x/crypto/ssh" +) + +// Backend is an interface that handles repositories management and any +// non-Git related operations. +type Backend interface { + // ServerName returns the server's name. + ServerName() string + // SetServerName sets the server's name. + SetServerName(name string) error + // ServerHost returns the server's host. + ServerHost() string + // SetServerHost sets the server's host. + SetServerHost(host string) error + // ServerPort returns the server's port. + ServerPort() string + // SetServerPort sets the server's port. + SetServerPort(port string) error + + // AnonAccess returns the access level for anonymous users. + AnonAccess() AccessLevel + // SetAnonAccess sets the access level for anonymous users. + SetAnonAccess(level AccessLevel) error + // AllowKeyless returns true if keyless access is allowed. + AllowKeyless() bool + // SetAllowKeyless sets whether or not keyless access is allowed. + SetAllowKeyless(allow bool) error + + // Repository finds the given repository. + Repository(repo string) (Repository, error) + // Repositories returns a list of all repositories. + Repositories() ([]Repository, error) + // CreateRepository creates a new repository. + CreateRepository(name string, private bool) (Repository, error) + // DeleteRepository deletes a repository. + DeleteRepository(name string) error + // RenameRepository renames a repository. + RenameRepository(oldName, newName string) error + // DefaultBranch returns the repository's default branch. + DefaultBranch(repo string) (string, error) + // SetDefaultBranch sets the default branch for a repository. + SetDefaultBranch(repo string, branch string) error + + // Description returns the repo's description. + Description(repo string) string + // SetDescription sets the repo's description. + SetDescription(repo, desc string) error + // IsPrivate returns true if the repository is private. + IsPrivate(repo string) bool + // SetPrivate sets the repository's private status. + SetPrivate(repo string, priv bool) error + + // IsCollaborator returns true if the authorized key is a collaborator on the repository. + IsCollaborator(pk ssh.PublicKey, repo string) bool + // AddCollaborator adds the authorized key as a collaborator on the repository. + AddCollaborator(pk ssh.PublicKey, repo string) error + // IsAdmin returns true if the authorized key is an admin. + IsAdmin(pk ssh.PublicKey) bool + // AddAdmin adds the authorized key as an admin. + AddAdmin(pk ssh.PublicKey) error +} + +// ParseAuthorizedKey parses an authorized key string into a public key. +func ParseAuthorizedKey(ak string) (ssh.PublicKey, string, error) { + pk, c, _, _, err := ssh.ParseAuthorizedKey([]byte(ak)) + return pk, c, err +} + +// MarshalAuthorizedKey marshals a public key into an authorized key string. +// +// This is the inverse of ParseAuthorizedKey. +// This function is a copy of ssh.MarshalAuthorizedKey, but without the trailing newline. +// It returns an empty string if pk is nil. +func MarshalAuthorizedKey(pk ssh.PublicKey) string { + if pk == nil { + return "" + } + return string(bytes.TrimSuffix(ssh.MarshalAuthorizedKey(pk), []byte("\n"))) +} diff --git a/server/backend/file/file.go b/server/backend/file/file.go new file mode 100644 index 0000000000000000000000000000000000000000..9cdcd3e183e3214bb44ed90fc344c01f495f39ef --- /dev/null +++ b/server/backend/file/file.go @@ -0,0 +1,598 @@ +// Package file implements a backend that uses the filesystem to store non-Git related data +// +// The following files and directories are used: +// +// - anon-access: contains the access level for anonymous users +// - allow-keyless: contains a boolean value indicating whether or not keyless access is allowed +// - admins: contains a list of authorized keys for admin users +// - host: contains the server's server hostname +// - name: contains the server's name +// - port: contains the server's port +// - repos: is a the directory containing all Git repositories +// +// Each repository has the following files and directories: +// - collaborators: contains a list of authorized keys for collaborators +// - description: contains the repository's description +// - private: when present, indicates that the repository is private +// - git-daemon-export-ok: when present, indicates that the repository is public +// - project-name: contains the repository's project name +package file + +import ( + "bufio" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/charmbracelet/log" + "github.com/charmbracelet/soft-serve/git" + "github.com/charmbracelet/soft-serve/server/backend" + "github.com/gliderlabs/ssh" + gitm "github.com/gogs/git-module" + gossh "golang.org/x/crypto/ssh" +) + +// sub file and directory names. +const ( + anonAccess = "anon-access" + allowKeyless = "allow-keyless" + admins = "admins" + serverHost = "host" + serverName = "name" + serverPort = "port" + repos = "repos" + collabs = "collaborators" + description = "description" + private = "private" +) + +var ( + logger = log.WithPrefix("backend.file") +) + +var _ backend.Backend = &FileBackend{} + +var _ backend.AccessMethod = &FileBackend{} + +// FileBackend is a backend that uses the filesystem. +type FileBackend struct { // nolint:revive + // path is the path to the directory containing the repositories and config + // files. + path string + // AdditionalAdmins additional admins to the server. + AdditionalAdmins []string +} + +func (fb *FileBackend) reposPath() string { + return filepath.Join(fb.path, repos) +} + +func (fb *FileBackend) adminsPath() string { + return filepath.Join(fb.path, admins) +} + +func (fb *FileBackend) collabsPath(repo string) string { + return filepath.Join(fb.reposPath(), repo, collabs) +} + +func sanatizeRepo(repo string) string { + return strings.TrimSuffix(repo, ".git") +} + +func readOneLine(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", err + } + defer f.Close() // nolint:errcheck + s := bufio.NewScanner(f) + s.Scan() + return s.Text(), s.Err() +} + +func readAll(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", err + } + + bts, err := io.ReadAll(f) + return string(bts), err +} + +// NewFileBackend creates a new FileBackend. +func NewFileBackend(path string) (*FileBackend, error) { + fb := &FileBackend{path: path} + for _, dir := range []string{repos} { + if err := os.MkdirAll(filepath.Join(path, dir), 0755); err != nil { + return nil, err + } + } + for _, file := range []string{admins, anonAccess, allowKeyless, serverHost, serverName, serverPort} { + if _, err := os.OpenFile(filepath.Join(path, file), os.O_RDONLY|os.O_CREATE, 0644); err != nil { + return nil, err + } + } + return fb, nil +} + +// AccessLevel returns the access level for the given public key and repo. +// +// It implements backend.AccessMethod. +func (fb *FileBackend) AccessLevel(repo string, pk gossh.PublicKey) backend.AccessLevel { + private := fb.IsPrivate(repo) + anon := fb.AnonAccess() + if pk != nil { + // Check if the key is an admin. + if fb.IsAdmin(pk) { + return backend.AdminAccess + } + + // Check if the key is a collaborator. + if fb.IsCollaborator(pk, repo) { + if anon > backend.ReadWriteAccess { + return anon + } + return backend.ReadWriteAccess + } + + // Check if repo is private. + if !private { + if anon > backend.ReadOnlyAccess { + return anon + } + return backend.ReadOnlyAccess + } + } + + if private { + return backend.NoAccess + } + + return anon +} + +// AddAdmin adds a public key to the list of server admins. +// +// It implements backend.Backend. +func (fb *FileBackend) AddAdmin(pk gossh.PublicKey) error { + // Skip if the key already exists. + if fb.IsAdmin(pk) { + return nil + } + + ak := backend.MarshalAuthorizedKey(pk) + f, err := os.OpenFile(fb.adminsPath(), os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0644) + if err != nil { + logger.Debug("failed to open admin keys file", "err", err, "path", fb.adminsPath()) + return err + } + + defer f.Close() //nolint:errcheck + _, err = fmt.Fprintln(f, ak) + return err +} + +// AddCollaborator adds a public key to the list of collaborators for the given repo. +// +// It implements backend.Backend. +func (fb *FileBackend) AddCollaborator(pk gossh.PublicKey, repo string) error { + // Skip if the key already exists. + if fb.IsCollaborator(pk, repo) { + return nil + } + + ak := backend.MarshalAuthorizedKey(pk) + repo = sanatizeRepo(repo) + ".git" + f, err := os.OpenFile(fb.collabsPath(repo), os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0644) + if err != nil { + logger.Debug("failed to open collaborators file", "err", err, "path", fb.collabsPath(repo)) + return err + } + + defer f.Close() //nolint:errcheck + _, err = fmt.Fprintln(f, ak) + return err +} + +// AllowKeyless returns true if keyless access is allowed. +// +// It implements backend.Backend. +func (fb *FileBackend) AllowKeyless() bool { + line, err := readOneLine(filepath.Join(fb.path, allowKeyless)) + if err != nil { + logger.Debug("failed to read allow-keyless file", "err", err) + return false + } + + return line == "true" +} + +// AnonAccess returns the level of anonymous access allowed. +// +// It implements backend.Backend. +func (fb *FileBackend) AnonAccess() backend.AccessLevel { + line, err := readOneLine(filepath.Join(fb.path, anonAccess)) + if err != nil { + logger.Debug("failed to read anon-access file", "err", err) + return backend.NoAccess + } + + switch line { + case backend.NoAccess.String(): + return backend.NoAccess + case backend.ReadOnlyAccess.String(): + return backend.ReadOnlyAccess + case backend.ReadWriteAccess.String(): + return backend.ReadWriteAccess + case backend.AdminAccess.String(): + return backend.AdminAccess + default: + return backend.NoAccess + } +} + +// Description returns the description of the given repo. +// +// It implements backend.Backend. +func (fb *FileBackend) Description(repo string) string { + repo = sanatizeRepo(repo) + ".git" + r := &Repo{path: filepath.Join(fb.reposPath(), repo)} + return r.Description() +} + +// IsAdmin checks if the given public key is a server admin. +// +// It implements backend.Backend. +func (fb *FileBackend) IsAdmin(pk gossh.PublicKey) bool { + // Check if the key is an additional admin. + ak := backend.MarshalAuthorizedKey(pk) + for _, admin := range fb.AdditionalAdmins { + if ak == admin { + return true + } + } + + f, err := os.Open(fb.adminsPath()) + if err != nil { + logger.Debug("failed to open admins file", "err", err, "path", fb.adminsPath()) + return false + } + + defer f.Close() //nolint:errcheck + s := bufio.NewScanner(f) + for s.Scan() { + apk, _, err := backend.ParseAuthorizedKey(s.Text()) + if err != nil { + continue + } + if ssh.KeysEqual(apk, pk) { + return true + } + } + + return false +} + +// IsCollaborator returns true if the given public key is a collaborator on the +// given repo. +// +// It implements backend.Backend. +func (fb *FileBackend) IsCollaborator(pk gossh.PublicKey, repo string) bool { + repo = sanatizeRepo(repo) + ".git" + f, err := os.Open(fb.collabsPath(repo)) + if err != nil { + logger.Debug("failed to open collaborators file", "err", err, "path", fb.collabsPath(repo)) + return false + } + + defer f.Close() //nolint:errcheck + s := bufio.NewScanner(f) + for s.Scan() { + apk, _, err := backend.ParseAuthorizedKey(s.Text()) + if err != nil { + continue + } + if ssh.KeysEqual(apk, pk) { + return true + } + } + + return false +} + +// IsPrivate returns true if the given repo is private. +// +// It implements backend.Backend. +func (fb *FileBackend) IsPrivate(repo string) bool { + repo = sanatizeRepo(repo) + ".git" + r := &Repo{path: filepath.Join(fb.reposPath(), repo)} + return r.IsPrivate() +} + +// ServerHost returns the server host. +// +// It implements backend.Backend. +func (fb *FileBackend) ServerHost() string { + line, err := readOneLine(filepath.Join(fb.path, serverHost)) + if err != nil { + logger.Debug("failed to read server-host file", "err", err) + return "" + } + + return line +} + +// ServerName returns the server name. +// +// It implements backend.Backend. +func (fb *FileBackend) ServerName() string { + line, err := readOneLine(filepath.Join(fb.path, serverName)) + if err != nil { + logger.Debug("failed to read server-name file", "err", err) + return "" + } + + return line +} + +// ServerPort returns the server port. +// +// It implements backend.Backend. +func (fb *FileBackend) ServerPort() string { + line, err := readOneLine(filepath.Join(fb.path, serverPort)) + if err != nil { + logger.Debug("failed to read server-port file", "err", err) + return "" + } + + if _, err := strconv.Atoi(line); err != nil { + logger.Debug("failed to parse server-port file", "err", err) + return "" + } + + return line +} + +// SetAllowKeyless sets whether or not to allow keyless access. +// +// It implements backend.Backend. +func (fb *FileBackend) SetAllowKeyless(allow bool) error { + f, err := os.OpenFile(filepath.Join(fb.path, allowKeyless), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) + if err != nil { + return fmt.Errorf("failed to open allow-keyless file: %w", err) + } + + defer f.Close() //nolint:errcheck + _, err = fmt.Fprintln(f, allow) + return err +} + +// SetAnonAccess sets the anonymous access level. +// +// It implements backend.Backend. +func (fb *FileBackend) SetAnonAccess(level backend.AccessLevel) error { + f, err := os.OpenFile(filepath.Join(fb.path, anonAccess), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) + if err != nil { + return fmt.Errorf("failed to open anon-access file: %w", err) + } + + defer f.Close() //nolint:errcheck + _, err = fmt.Fprintln(f, level.String()) + return err +} + +// SetDescription sets the description of the given repo. +// +// It implements backend.Backend. +func (fb *FileBackend) SetDescription(repo string, desc string) error { + f, err := os.OpenFile(filepath.Join(fb.reposPath(), repo, description), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) + if err != nil { + return fmt.Errorf("failed to open description file: %w", err) + } + + defer f.Close() //nolint:errcheck + _, err = fmt.Fprintln(f, desc) + return err +} + +// SetPrivate sets the private status of the given repo. +// +// It implements backend.Backend. +func (fb *FileBackend) SetPrivate(repo string, priv bool) error { + repo = sanatizeRepo(repo) + ".git" + daemonExport := filepath.Join(fb.reposPath(), repo, "git-daemon-export-ok") + if priv { + _ = os.Remove(daemonExport) + f, err := os.Create(filepath.Join(fb.reposPath(), repo, private)) + if err != nil { + return fmt.Errorf("failed to create private file: %w", err) + } + + _ = f.Close() //nolint:errcheck + } else { + // Create git-daemon-export-ok file if repo is public. + f, err := os.Create(daemonExport) + if err != nil { + logger.Warn("failed to create git-daemon-export-ok file", "err", err) + } else { + _ = f.Close() //nolint:errcheck + } + } + return nil +} + +// SetServerHost sets the server host. +// +// It implements backend.Backend. +func (fb *FileBackend) SetServerHost(host string) error { + f, err := os.Create(filepath.Join(fb.path, serverHost)) + if err != nil { + return fmt.Errorf("failed to create server-host file: %w", err) + } + + defer f.Close() //nolint:errcheck + _, err = fmt.Fprintln(f, host) + return err +} + +// SetServerName sets the server name. +// +// It implements backend.Backend. +func (fb *FileBackend) SetServerName(name string) error { + f, err := os.Create(filepath.Join(fb.path, serverName)) + if err != nil { + return fmt.Errorf("failed to create server-name file: %w", err) + } + + defer f.Close() //nolint:errcheck + _, err = fmt.Fprintln(f, name) + return err +} + +// SetServerPort sets the server port. +// +// It implements backend.Backend. +func (fb *FileBackend) SetServerPort(port string) error { + f, err := os.Create(filepath.Join(fb.path, serverPort)) + if err != nil { + return fmt.Errorf("failed to create server-port file: %w", err) + } + + defer f.Close() //nolint:errcheck + _, err = fmt.Fprintln(f, port) + return err +} + +// CreateRepository creates a new repository. +// +// Created repositories are always bare. +// +// It implements backend.Backend. +func (fb *FileBackend) CreateRepository(name string, private bool) (backend.Repository, error) { + name = sanatizeRepo(name) + ".git" + rp := filepath.Join(fb.reposPath(), name) + if _, err := git.Init(rp, true); err != nil { + logger.Debug("failed to create repository", "err", err) + return nil, err + } + + fb.SetPrivate(name, private) + + return &Repo{path: rp}, nil +} + +// DeleteRepository deletes the given repository. +// +// It implements backend.Backend. +func (fb *FileBackend) DeleteRepository(name string) error { + name = sanatizeRepo(name) + ".git" + return os.RemoveAll(filepath.Join(fb.reposPath(), name)) +} + +// RenameRepository renames the given repository. +// +// It implements backend.Backend. +func (fb *FileBackend) RenameRepository(oldName string, newName string) error { + oldName = sanatizeRepo(oldName) + ".git" + newName = sanatizeRepo(newName) + ".git" + return os.Rename(filepath.Join(fb.reposPath(), oldName), filepath.Join(fb.reposPath(), newName)) +} + +// Repository finds the given repository. +// +// It implements backend.Backend. +func (fb *FileBackend) Repository(repo string) (backend.Repository, error) { + repo = sanatizeRepo(repo) + ".git" + rp := filepath.Join(fb.reposPath(), repo) + _, err := os.Stat(rp) + if !errors.Is(err, os.ErrExist) { + return nil, err + } + + return &Repo{path: rp}, nil +} + +// Repositories returns a list of all repositories. +// +// It implements backend.Backend. +func (fb *FileBackend) Repositories() ([]backend.Repository, error) { + repos := make([]backend.Repository, 0) + err := filepath.WalkDir(fb.reposPath(), func(path string, d fs.DirEntry, _ error) error { + // Skip non-directories. + if !d.IsDir() { + return nil + } + + // Skip non-repositories. + if !strings.HasSuffix(path, ".git") { + return nil + } + + repos = append(repos, &Repo{path: path}) + + return nil + }) + if err != nil { + return nil, err + } + + return repos, nil +} + +// DefaultBranch returns the default branch of the given repository. +// +// It implements backend.Backend. +func (fb *FileBackend) DefaultBranch(repo string) (string, error) { + rr, err := fb.Repository(repo) + if err != nil { + logger.Debug("failed to get default branch", "err", err) + return "", err + } + + r, err := rr.Repository() + if err != nil { + logger.Debug("failed to open repository for default branch", "err", err) + return "", err + } + + head, err := r.HEAD() + if err != nil { + logger.Debug("failed to get HEAD for default branch", "err", err) + return "", err + } + + return head.Name().Short(), nil +} + +// SetDefaultBranch sets the default branch for the given repository. +// +// It implements backend.Backend. +func (fb *FileBackend) SetDefaultBranch(repo string, branch string) error { + rr, err := fb.Repository(repo) + if err != nil { + logger.Debug("failed to get repository for default branch", "err", err) + return err + } + + r, err := rr.Repository() + if err != nil { + logger.Debug("failed to open repository for default branch", "err", err) + return err + } + + if _, err := r.SymbolicRef(gitm.SymbolicRefOptions{ + Name: "HEAD", + Ref: gitm.RefsHeads + branch, + }); err != nil { + logger.Debug("failed to set default branch", "err", err) + return err + } + + return nil +} diff --git a/server/backend/file/repo.go b/server/backend/file/repo.go new file mode 100644 index 0000000000000000000000000000000000000000..6764f5fe3164fa5518712159b5f140d895c6bfa3 --- /dev/null +++ b/server/backend/file/repo.go @@ -0,0 +1,56 @@ +package file + +import ( + "errors" + "os" + "path/filepath" + "strings" + + "github.com/charmbracelet/soft-serve/git" + "github.com/charmbracelet/soft-serve/server/backend" +) + +var _ backend.Repository = (*Repo)(nil) + +// Repo is a filesystem Git repository. +// +// It implemenets backend.Repository. +type Repo struct { + path string +} + +// Name returns the repository's name. +// +// It implements backend.Repository. +func (r *Repo) Name() string { + return strings.TrimSuffix(filepath.Base(r.path), ".git") +} + +// Description returns the repository's description. +// +// It implements backend.Repository. +func (r *Repo) Description() string { + desc, err := readAll(r.path) + if err != nil { + logger.Debug("failed to read description file", "err", err, + "path", filepath.Join(r.path, description)) + return "" + } + + return desc +} + +// IsPrivate returns whether the repository is private. +// +// It implements backend.Repository. +func (r *Repo) IsPrivate() bool { + _, err := os.Stat(filepath.Join(r.path, private)) + return errors.Is(err, os.ErrExist) +} + +// Repository returns the underlying git.Repository. +// +// It implements backend.Repository. +func (r *Repo) Repository() (*git.Repository, error) { + return git.Open(r.path) +} diff --git a/server/backend/repo.go b/server/backend/repo.go new file mode 100644 index 0000000000000000000000000000000000000000..846134b83e9873ae0874ad5e9f702ab7b801a78b --- /dev/null +++ b/server/backend/repo.go @@ -0,0 +1,15 @@ +package backend + +import "github.com/charmbracelet/soft-serve/git" + +// Repository is a Git repository interface. +type Repository interface { + // Name returns the repository's name. + Name() string + // Description returns the repository's description. + Description() string + // IsPrivate returns whether the repository is private. + IsPrivate() bool + // Repository returns the underlying git.Repository. + Repository() (*git.Repository, error) +} diff --git a/server/backend/utils.go b/server/backend/utils.go new file mode 100644 index 0000000000000000000000000000000000000000..6f7d87b3239c3889607467e67d1e981713c0f5ab --- /dev/null +++ b/server/backend/utils.go @@ -0,0 +1,51 @@ +package backend + +import ( + "path/filepath" + + "github.com/charmbracelet/soft-serve/git" + "github.com/gobwas/glob" +) + +// LatestFile returns the contents of the latest file at the specified path in +// the repository and its file path. +func LatestFile(r Repository, pattern string) (string, string, error) { + g := glob.MustCompile(pattern) + dir := filepath.Dir(pattern) + repo, err := r.Repository() + if err != nil { + return "", "", err + } + head, err := repo.HEAD() + if err != nil { + return "", "", err + } + t, err := repo.TreePath(head, dir) + if err != nil { + return "", "", err + } + ents, err := t.Entries() + if err != nil { + return "", "", err + } + for _, e := range ents { + fp := filepath.Join(dir, e.Name()) + if e.IsTree() { + continue + } + if g.Match(fp) { + bts, err := e.Contents() + if err != nil { + return "", "", err + } + return string(bts), fp, nil + } + } + return "", "", git.ErrFileNotFound +} + +// Readme returns the repository's README. +func Readme(r Repository) (readme string, path string, err error) { + readme, path, err = LatestFile(r, "README*") + return +}