Files
codit/backend/internal/git/browse.go

577 lines
13 KiB
Go

package git
import "errors"
import "io"
import "strings"
import git "github.com/go-git/go-git/v5"
import "github.com/go-git/go-git/v5/plumbing"
import "github.com/go-git/go-git/v5/plumbing/filemode"
import "github.com/go-git/go-git/v5/plumbing/format/diff"
import "github.com/go-git/go-git/v5/plumbing/object"
import "github.com/go-git/go-git/v5/plumbing/storer"
type Commit struct {
Hash string `json:"hash"`
Author string `json:"author"`
Email string `json:"email"`
When string `json:"when"`
Message string `json:"message"`
}
type CommitDetail struct {
Hash string `json:"hash"`
Author string `json:"author"`
Email string `json:"email"`
When string `json:"when"`
Message string `json:"message"`
Files []CommitFile `json:"files"`
}
type CommitFile struct {
Path string `json:"path"`
Status string `json:"status"`
}
type TreeEntry struct {
Name string `json:"name"`
Path string `json:"path"`
Type string `json:"type"`
}
var ErrPathNotFound = errors.New("path not found")
func ListBranches(repoPath string) ([]string, error) {
var repo *git.Repository
var err error
var iter storer.ReferenceIter
var branches []string
repo, err = git.PlainOpen(repoPath)
if err != nil {
return nil, err
}
iter, err = repo.Branches()
if err != nil {
return nil, err
}
defer iter.Close()
err = iter.ForEach(func(ref *plumbing.Reference) error {
if ref.Hash().IsZero() {
return nil
}
branches = append(branches, ref.Name().Short())
return nil
})
if err != nil {
return nil, err
}
return branches, nil
}
func ListCommits(repoPath, ref string, limit, offset int, query string) ([]Commit, error) {
var repo *git.Repository
var err error
var commit *object.Commit
var iter object.CommitIter
var commits []Commit
var matchIndex int
var msg string
var author string
var email string
repo, err = git.PlainOpen(repoPath)
if err != nil {
return nil, err
}
commit, err = resolveCommit(repo, ref)
if err != nil {
if isReferenceNotFound(err) {
return []Commit{}, nil
}
return nil, err
}
iter, err = repo.Log(&git.LogOptions{From: commit.Hash})
if err != nil {
return nil, err
}
defer iter.Close()
query = strings.ToLower(strings.TrimSpace(query))
err = iter.ForEach(func(c *object.Commit) error {
if query != "" {
msg = strings.ToLower(c.Message)
author = strings.ToLower(c.Author.Name)
email = strings.ToLower(c.Author.Email)
if !strings.Contains(msg, query) && !strings.Contains(author, query) && !strings.Contains(email, query) {
return nil
}
}
if matchIndex < offset {
matchIndex++
return nil
}
commits = append(commits, Commit{
Hash: c.Hash.String(),
Author: c.Author.Name,
Email: c.Author.Email,
When: c.Author.When.UTC().Format(timeFormat),
Message: strings.TrimSpace(c.Message),
})
matchIndex++
if limit > 0 && len(commits) >= limit {
return storer.ErrStop
}
return nil
})
if err != nil && err != storer.ErrStop {
return nil, err
}
return commits, nil
}
func ListFileHistory(repoPath, ref, path string, limit int) ([]Commit, error) {
var repo *git.Repository
var err error
var commit *object.Commit
var iter object.CommitIter
var commits []Commit
var count int
repo, err = git.PlainOpen(repoPath)
if err != nil {
return nil, err
}
commit, err = resolveCommit(repo, ref)
if err != nil {
if isReferenceNotFound(err) {
return []Commit{}, nil
}
return nil, err
}
iter, err = repo.Log(&git.LogOptions{
From: commit.Hash,
PathFilter: func(p string) bool {
return p == path
},
})
if err != nil {
return nil, err
}
defer iter.Close()
count = 0
err = iter.ForEach(func(c *object.Commit) error {
commits = append(commits, Commit{
Hash: c.Hash.String(),
Author: c.Author.Name,
Email: c.Author.Email,
When: c.Author.When.UTC().Format(timeFormat),
Message: strings.TrimSpace(c.Message),
})
count++
if limit > 0 && count >= limit {
return storer.ErrStop
}
return nil
})
if err != nil && err != storer.ErrStop {
return nil, err
}
return commits, nil
}
func DiffFile(repoPath, ref, path string) (string, error) {
var repo *git.Repository
var err error
var commit *object.Commit
var parent *object.Commit
var patch *object.Patch
var i int
var filePatches []diff.FilePatch
var fp diff.FilePatch
var from diff.File
var to diff.File
var header string
var b strings.Builder
var chunks []diff.Chunk
var chunk diff.Chunk
repo, err = git.PlainOpen(repoPath)
if err != nil {
return "", err
}
commit, err = resolveCommit(repo, ref)
if err != nil {
return "", err
}
if commit.NumParents() == 0 {
return "", nil
}
parent, err = commit.Parent(0)
if err != nil {
return "", err
}
patch, err = parent.Patch(commit)
if err != nil {
return "", err
}
filePatches = patch.FilePatches()
for i = 0; i < len(filePatches); i++ {
fp = filePatches[i]
from, to = fp.Files()
if (from != nil && from.Path() == path) || (to != nil && to.Path() == path) {
header = "diff --git"
if from != nil {
header += " a/" + from.Path()
} else {
header += " a/" + path
}
if to != nil {
header += " b/" + to.Path()
} else {
header += " b/" + path
}
header += "\n"
b.WriteString(header)
chunks = fp.Chunks()
for i = 0; i < len(chunks); i++ {
chunk = chunks[i]
b.WriteString(chunk.Content())
}
return b.String(), nil
}
}
return "", nil
}
func GetCommitDetail(repoPath, hash string) (CommitDetail, error) {
var repo *git.Repository
var err error
var commit *object.Commit
var parent *object.Commit
var patch *object.Patch
var files []CommitFile
var tree *object.Tree
var iter *object.FileIter
var file *object.File
var filePatches []diff.FilePatch
var fp diff.FilePatch
var from diff.File
var to diff.File
var path string
var status string
var seen map[string]string
repo, err = git.PlainOpen(repoPath)
if err != nil {
return CommitDetail{}, err
}
commit, err = repo.CommitObject(plumbing.NewHash(hash))
if err != nil {
return CommitDetail{}, err
}
if commit.NumParents() == 0 {
tree, err = commit.Tree()
if err != nil {
return CommitDetail{}, err
}
iter = tree.Files()
err = iter.ForEach(func(f *object.File) error {
file = f
files = append(files, CommitFile{Path: file.Name, Status: "added"})
return nil
})
if err != nil {
return CommitDetail{}, err
}
} else {
parent, err = commit.Parent(0)
if err != nil {
return CommitDetail{}, err
}
patch, err = parent.Patch(commit)
if err != nil {
return CommitDetail{}, err
}
filePatches = patch.FilePatches()
seen = map[string]string{}
for _, fp = range filePatches {
from, to = fp.Files()
if from == nil && to == nil {
continue
}
if to != nil {
path = to.Path()
} else {
path = from.Path()
}
if from == nil {
status = "added"
} else if to == nil {
status = "deleted"
} else if from.Path() != to.Path() {
status = "renamed"
} else {
status = "modified"
}
seen[path] = status
}
for path, status = range seen {
files = append(files, CommitFile{Path: path, Status: status})
}
}
return CommitDetail{
Hash: commit.Hash.String(),
Author: commit.Author.Name,
Email: commit.Author.Email,
When: commit.Author.When.UTC().Format(timeFormat),
Message: strings.TrimSpace(commit.Message),
Files: files,
}, nil
}
func CommitDiff(repoPath, hash string) (string, error) {
var repo *git.Repository
var err error
var commit *object.Commit
var parent *object.Commit
var patch *object.Patch
repo, err = git.PlainOpen(repoPath)
if err != nil {
return "", err
}
commit, err = repo.CommitObject(plumbing.NewHash(hash))
if err != nil {
return "", err
}
if commit.NumParents() == 0 {
return "", nil
}
parent, err = commit.Parent(0)
if err != nil {
return "", err
}
patch, err = parent.Patch(commit)
if err != nil {
return "", err
}
return patch.String(), nil
}
func ListCommitsBetween(repoPath, baseRef, headRef string, limit int) ([]Commit, error) {
var repo *git.Repository
var err error
var head *object.Commit
var base *object.Commit
var iter object.CommitIter
var commits []Commit
var count int
repo, err = git.PlainOpen(repoPath)
if err != nil {
return nil, err
}
head, err = resolveCommit(repo, headRef)
if err != nil {
return nil, err
}
base, err = resolveCommit(repo, baseRef)
if err != nil {
return nil, err
}
iter, err = repo.Log(&git.LogOptions{From: head.Hash})
if err != nil {
return nil, err
}
defer iter.Close()
count = 0
err = iter.ForEach(func(c *object.Commit) error {
if c.Hash == base.Hash {
return storer.ErrStop
}
commits = append(commits, Commit{
Hash: c.Hash.String(),
Author: c.Author.Name,
Email: c.Author.Email,
When: c.Author.When.UTC().Format(timeFormat),
Message: strings.TrimSpace(c.Message),
})
count++
if limit > 0 && count >= limit {
return storer.ErrStop
}
return nil
})
if err != nil && err != storer.ErrStop {
return nil, err
}
return commits, nil
}
func ListTree(repoPath, ref, path string) ([]TreeEntry, error) {
var repo *git.Repository
var err error
var commit *object.Commit
var tree *object.Tree
var entries []TreeEntry
var i int
var entry object.TreeEntry
var typeName string
var entryPath string
repo, err = git.PlainOpen(repoPath)
if err != nil {
return nil, err
}
commit, err = resolveCommit(repo, ref)
if err != nil {
if isReferenceNotFound(err) {
return []TreeEntry{}, nil
}
return nil, err
}
tree, err = commit.Tree()
if err != nil {
return nil, err
}
if path != "" {
tree, err = tree.Tree(path)
if err != nil {
if errors.Is(err, object.ErrEntryNotFound) || errors.Is(err, object.ErrDirectoryNotFound) {
return nil, ErrPathNotFound
}
return nil, err
}
}
entries = make([]TreeEntry, 0, len(tree.Entries))
for i = 0; i < len(tree.Entries); i++ {
entry = tree.Entries[i]
typeName = "file"
if entry.Mode == filemode.Dir {
typeName = "dir"
}
entryPath = entry.Name
if path != "" {
entryPath = path + "/" + entry.Name
}
entries = append(entries, TreeEntry{
Name: entry.Name,
Path: entryPath,
Type: typeName,
})
}
return entries, nil
}
func ReadBlob(repoPath, ref, path string, maxBytes int64) (string, error) {
var repo *git.Repository
var err error
var commit *object.Commit
var tree *object.Tree
var file *object.File
var reader io.ReadCloser
var limited *io.LimitedReader
var data []byte
repo, err = git.PlainOpen(repoPath)
if err != nil {
return "", err
}
commit, err = resolveCommit(repo, ref)
if err != nil {
return "", err
}
tree, err = commit.Tree()
if err != nil {
return "", err
}
file, err = tree.File(path)
if err != nil {
return "", err
}
reader, err = file.Reader()
if err != nil {
return "", err
}
defer reader.Close()
if maxBytes <= 0 {
maxBytes = 200 * 1024
}
limited = &io.LimitedReader{R: reader, N: maxBytes}
data, err = io.ReadAll(limited)
if err != nil {
return "", err
}
return string(data), nil
}
func ReadBlobBytes(repoPath, ref, path string, maxBytes int64) ([]byte, error) {
var repo *git.Repository
var err error
var commit *object.Commit
var tree *object.Tree
var file *object.File
var reader io.ReadCloser
var limited *io.LimitedReader
var data []byte
repo, err = git.PlainOpen(repoPath)
if err != nil {
return nil, err
}
commit, err = resolveCommit(repo, ref)
if err != nil {
return nil, err
}
tree, err = commit.Tree()
if err != nil {
return nil, err
}
file, err = tree.File(path)
if err != nil {
return nil, err
}
reader, err = file.Reader()
if err != nil {
return nil, err
}
defer reader.Close()
if maxBytes <= 0 {
maxBytes = 5 * 1024 * 1024
}
limited = &io.LimitedReader{R: reader, N: maxBytes}
data, err = io.ReadAll(limited)
if err != nil {
return nil, err
}
return data, nil
}
const timeFormat = "2006-01-02 15:04:05Z"
func resolveCommit(repo *git.Repository, ref string) (*object.Commit, error) {
var commit *object.Commit
var err error
ref = strings.TrimSpace(ref)
if ref == "" {
ref = "HEAD"
}
commit, err = resolveCommitByRef(repo, ref)
if err == nil {
return commit, nil
}
if ref != "HEAD" && !strings.HasPrefix(ref, "refs/") {
return resolveCommitByRef(repo, "refs/heads/"+ref)
}
return nil, err
}
func resolveCommitByRef(repo *git.Repository, ref string) (*object.Commit, error) {
var hash *plumbing.Hash
var err error
var commit *object.Commit
hash, err = repo.ResolveRevision(plumbing.Revision(ref))
if err != nil {
return nil, err
}
commit, err = repo.CommitObject(*hash)
if err != nil {
return nil, err
}
return commit, nil
}
func isReferenceNotFound(err error) bool {
return errors.Is(err, plumbing.ErrReferenceNotFound)
}