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) }