package repository
import (
"bytes"
"errors"
"io"
"sort"
"strings"
"unicode/utf8"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/filemode"
"github.com/go-git/go-git/v5/plumbing/object"
)
var (
ErrNotTree = errors.New("path is not a tree")
ErrNotFile = errors.New("path is not a file")
ErrEmptyRepository = errors.New("repository has no commits")
)
var readmeNames = []string{
"README.md",
"README.txt",
"README",
"Readme.md",
}
type Repository struct {
name string
path string
repo *git.Repository
}
type ResolvedRevision struct {
Name string
Hash plumbing.Hash
Commit *object.Commit
}
type TreeEntry struct {
Name string
Path string
IsDir bool
Mode filemode.FileMode
}
type Readme struct {
Name string
Content string
}
type Blob struct {
Name string
Path string
Content []byte
ContentType string
Text bool
}
type CommitPage struct {
Commits []*object.Commit
HasMore bool
}
type Branch struct {
Name string
Hash plumbing.Hash
Current bool
}
func Open(name, path string) (*Repository, error) {
repo, err := git.PlainOpen(path)
if err != nil {
return nil, err
}
return &Repository{name: name, path: path, repo: repo}, nil
}
func (r *Repository) Name() string {
return r.name
}
func (r *Repository) DefaultRevision() (*ResolvedRevision, error) {
head, err := r.repo.Head()
if err != nil {
if errors.Is(err, plumbing.ErrReferenceNotFound) {
return nil, ErrEmptyRepository
}
return nil, err
}
name := shortReferenceName(head)
commit, err := r.repo.CommitObject(head.Hash())
if err != nil {
return nil, err
}
return &ResolvedRevision{Name: name, Hash: head.Hash(), Commit: commit}, nil
}
func (r *Repository) ResolveRevision(spec string) (*ResolvedRevision, error) {
spec = strings.TrimSpace(spec)
if spec == "" || spec == "HEAD" {
return r.DefaultRevision()
}
hash, err := r.repo.ResolveRevision(plumbing.Revision(spec))
if err != nil {
if errors.Is(err, plumbing.ErrReferenceNotFound) {
return nil, ErrEmptyRepository
}
return nil, err
}
commit, err := r.repo.CommitObject(*hash)
if err != nil {
return nil, err
}
return &ResolvedRevision{Name: spec, Hash: *hash, Commit: commit}, nil
}
func (r *Repository) Tree(commit *object.Commit, treePath string) (*object.Tree, error) {
tree, err := commit.Tree()
if err != nil {
return nil, err
}
if treePath == "" {
return tree, nil
}
subtree, err := tree.Tree(treePath)
if err != nil {
if errors.Is(err, object.ErrDirectoryNotFound) || errors.Is(err, object.ErrEntryNotFound) {
return nil, ErrNotTree
}
return nil, err
}
return subtree, nil
}
func (r *Repository) TreeEntries(tree *object.Tree, treePath string) []TreeEntry {
entries := make([]TreeEntry, 0, len(tree.Entries))
for _, entry := range tree.Entries {
entryPath := joinGitPath(treePath, entry.Name)
entries = append(entries, TreeEntry{
Name: entry.Name,
Path: entryPath,
IsDir: entry.Mode == filemode.Dir,
Mode: entry.Mode,
})
}
sort.Slice(entries, func(i, j int) bool {
if entries[i].IsDir != entries[j].IsDir {
return entries[i].IsDir
}
return strings.ToLower(entries[i].Name) < strings.ToLower(entries[j].Name)
})
return entries
}
func (r *Repository) Readme(tree *object.Tree) (*Readme, error) {
for _, name := range readmeNames {
file, err := tree.File(name)
if err != nil {
if errors.Is(err, object.ErrFileNotFound) {
continue
}
return nil, err
}
content, err := readAll(file)
if err != nil {
return nil, err
}
return &Readme{Name: name, Content: string(content)}, nil
}
return nil, nil
}
func (r *Repository) Blob(commit *object.Commit, filePath string) (*Blob, error) {
tree, err := commit.Tree()
if err != nil {
return nil, err
}
file, err := tree.File(filePath)
if err != nil {
if errors.Is(err, object.ErrFileNotFound) {
return nil, ErrNotFile
}
return nil, err
}
content, err := readAll(file)
if err != nil {
return nil, err
}
contentType := detectContentType(content)
return &Blob{
Name: file.Name,
Path: filePath,
Content: content,
ContentType: contentType,
Text: isTextContent(content, contentType),
}, nil
}
func (r *Repository) Log(commit *object.Commit, filePath string, page, perPage int) (*CommitPage, error) {
options := &git.LogOptions{
From: commit.Hash,
Order: git.LogOrderCommitterTime,
}
if filePath != "" {
options.FileName = &filePath
}
iter, err := r.repo.Log(options)
if err != nil {
return nil, err
}
defer iter.Close()
skip := page * perPage
limit := skip + perPage + 1
items := make([]*object.Commit, 0, perPage+1)
index := 0
for {
next, err := iter.Next()
if err != nil {
if errors.Is(err, io.EOF) {
break
}
return nil, err
}
if index < skip {
index++
continue
}
if len(items) >= limit-skip {
break
}
items = append(items, next)
index++
}
hasMore := len(items) > perPage
if hasMore {
items = items[:perPage]
}
return &CommitPage{Commits: items, HasMore: hasMore}, nil
}
func (r *Repository) Branches() ([]Branch, error) {
head, err := r.repo.Head()
if err != nil {
if !errors.Is(err, plumbing.ErrReferenceNotFound) {
return nil, err
}
head = nil
}
iter, err := r.repo.Branches()
if err != nil {
return nil, err
}
defer iter.Close()
var branches []Branch
err = iter.ForEach(func(ref *plumbing.Reference) error {
current := false
if head != nil {
current = head.Name() == ref.Name()
}
branches = append(branches, Branch{
Name: ref.Name().Short(),
Hash: ref.Hash(),
Current: current,
})
return nil
})
if err != nil {
return nil, err
}
sort.Slice(branches, func(i, j int) bool {
return strings.ToLower(branches[i].Name) < strings.ToLower(branches[j].Name)
})
return branches, nil
}
func shortReferenceName(ref *plumbing.Reference) string {
if ref.Name().IsBranch() || ref.Name().IsTag() {
return ref.Name().Short()
}
if ref.Type() == plumbing.SymbolicReference {
return ref.Target().Short()
}
return shortHash(ref.Hash().String())
}
func shortHash(hash string) string {
if len(hash) <= 7 {
return hash
}
return hash[:7]
}
func joinGitPath(parent, name string) string {
if parent == "" {
return name
}
return parent + "/" + name
}
func readAll(file *object.File) ([]byte, error) {
reader, err := file.Reader()
if err != nil {
return nil, err
}
defer reader.Close()
return io.ReadAll(reader)
}
func detectContentType(content []byte) string {
if len(content) == 0 {
return "text/plain; charset=utf-8"
}
sniff := content
if len(sniff) > 512 {
sniff = sniff[:512]
}
return httpDetectContentType(sniff)
}
func isTextContent(content []byte, contentType string) bool {
if !utf8.Valid(content) {
return false
}
if bytes.IndexByte(content, 0) >= 0 {
return false
}
if strings.HasPrefix(contentType, "text/") {
return true
}
switch {
case strings.Contains(contentType, "json"),
strings.Contains(contentType, "xml"),
strings.Contains(contentType, "javascript"),
strings.Contains(contentType, "svg+xml"),
strings.Contains(contentType, "x-sh"):
return true
default:
return false
}
}