ikurotime / gitgud

public
main / internal/infra/git/gogit_reader.go
8.3 KB · Go Raw
  1package git
  2
  3import (
  4	"bytes"
  5	"context"
  6	"errors"
  7	"io"
  8	"path"
  9	"sort"
 10	"strings"
 11
 12	gogit "github.com/go-git/go-git/v5"
 13	"github.com/go-git/go-git/v5/plumbing"
 14	"github.com/go-git/go-git/v5/plumbing/filemode"
 15	"github.com/go-git/go-git/v5/plumbing/object"
 16
 17	"gitgud/internal/domain"
 18)
 19
 20type GoGitReader struct {
 21	reposDir string
 22}
 23
 24func NewGoGitReader(reposDir string) *GoGitReader {
 25	return &GoGitReader{reposDir: reposDir}
 26}
 27
 28func (g *GoGitReader) open(owner, name string) (*gogit.Repository, error) {
 29	p, err := repoPath(g.reposDir, owner, name)
 30	if err != nil {
 31		return nil, err
 32	}
 33	repo, err := gogit.PlainOpen(p)
 34	if err != nil {
 35		return nil, mapErr(err)
 36	}
 37	return repo, nil
 38}
 39
 40func (g *GoGitReader) resolveCommit(repo *gogit.Repository, ref string) (*object.Commit, error) {
 41	if strings.TrimSpace(ref) == "" {
 42		ref = "HEAD"
 43	}
 44	hash, err := repo.ResolveRevision(plumbing.Revision(ref))
 45	if err != nil {
 46		return nil, mapErr(err)
 47	}
 48	c, err := repo.CommitObject(*hash)
 49	if err != nil {
 50		return nil, mapErr(err)
 51	}
 52	return c, nil
 53}
 54
 55func (g *GoGitReader) IsEmpty(ctx context.Context, owner, name string) (bool, error) {
 56	repo, err := g.open(owner, name)
 57	if err != nil {
 58		return false, err
 59	}
 60	_, err = repo.Head()
 61	if errors.Is(err, plumbing.ErrReferenceNotFound) {
 62		return true, nil
 63	}
 64	if err != nil {
 65		return false, mapErr(err)
 66	}
 67	return false, nil
 68}
 69
 70func (g *GoGitReader) Branches(ctx context.Context, owner, name string) ([]string, error) {
 71	repo, err := g.open(owner, name)
 72	if err != nil {
 73		return nil, err
 74	}
 75	iter, err := repo.Branches()
 76	if err != nil {
 77		return nil, mapErr(err)
 78	}
 79	var names []string
 80	err = iter.ForEach(func(ref *plumbing.Reference) error {
 81		names = append(names, ref.Name().Short())
 82		return nil
 83	})
 84	if err != nil {
 85		return nil, mapErr(err)
 86	}
 87	sort.Strings(names)
 88	return names, nil
 89}
 90
 91func (g *GoGitReader) Tip(ctx context.Context, owner, name, ref string) (*domain.Commit, error) {
 92	repo, err := g.open(owner, name)
 93	if err != nil {
 94		return nil, err
 95	}
 96	c, err := g.resolveCommit(repo, ref)
 97	if err != nil {
 98		return nil, err
 99	}
100	return commitDTO(c), nil
101}
102
103func (g *GoGitReader) Tree(ctx context.Context, owner, name, ref, p string) ([]domain.TreeEntry, error) {
104	repo, err := g.open(owner, name)
105	if err != nil {
106		return nil, err
107	}
108	c, err := g.resolveCommit(repo, ref)
109	if err != nil {
110		return nil, err
111	}
112	tree, err := c.Tree()
113	if err != nil {
114		return nil, mapErr(err)
115	}
116	p = strings.Trim(p, "/")
117	if p != "" {
118		tree, err = tree.Tree(p)
119		if err != nil {
120			return nil, mapErr(err)
121		}
122	}
123
124	entries := make([]domain.TreeEntry, 0, len(tree.Entries))
125	for _, e := range tree.Entries {
126		isDir := e.Mode == filemode.Dir
127		var size int64
128		if !isDir {
129			if f, err := tree.TreeEntryFile(&e); err == nil {
130				size = f.Size
131			}
132		}
133		entries = append(entries, domain.TreeEntry{
134			Name:  e.Name,
135			Path:  path.Join(p, e.Name),
136			IsDir: isDir,
137			Size:  size,
138			Mode:  e.Mode.String(),
139		})
140	}
141
142	sort.Slice(entries, func(i, j int) bool {
143		if entries[i].IsDir != entries[j].IsDir {
144			return entries[i].IsDir
145		}
146		return entries[i].Name < entries[j].Name
147	})
148	return entries, nil
149}
150
151func (g *GoGitReader) Blob(ctx context.Context, owner, name, ref, p string) (*domain.FileBlob, error) {
152	repo, err := g.open(owner, name)
153	if err != nil {
154		return nil, err
155	}
156	c, err := g.resolveCommit(repo, ref)
157	if err != nil {
158		return nil, err
159	}
160	tree, err := c.Tree()
161	if err != nil {
162		return nil, mapErr(err)
163	}
164	f, err := tree.File(strings.Trim(p, "/"))
165	if err != nil {
166		return nil, mapErr(err)
167	}
168	reader, err := f.Reader()
169	if err != nil {
170		return nil, mapErr(err)
171	}
172	defer reader.Close()
173	content, err := io.ReadAll(reader)
174	if err != nil {
175		return nil, mapErr(err)
176	}
177	return &domain.FileBlob{
178		Path:     strings.Trim(p, "/"),
179		Content:  content,
180		IsBinary: isBinary(content),
181		Size:     f.Size,
182	}, nil
183}
184
185func (g *GoGitReader) Log(ctx context.Context, owner, name, ref string, limit, offset int) ([]domain.Commit, error) {
186	repo, err := g.open(owner, name)
187	if err != nil {
188		return nil, err
189	}
190	c, err := g.resolveCommit(repo, ref)
191	if err != nil {
192		return nil, err
193	}
194	iter, err := repo.Log(&gogit.LogOptions{From: c.Hash})
195	if err != nil {
196		return nil, mapErr(err)
197	}
198	defer iter.Close()
199
200	var commits []domain.Commit
201	skipped := 0
202	err = iter.ForEach(func(commit *object.Commit) error {
203		if skipped < offset {
204			skipped++
205			return nil
206		}
207		if limit > 0 && len(commits) >= limit {
208			return storerStop
209		}
210		commits = append(commits, *commitDTO(commit))
211		return nil
212	})
213	if err != nil && !errors.Is(err, storerStop) {
214		return nil, mapErr(err)
215	}
216	return commits, nil
217}
218
219func (g *GoGitReader) CommitDiff(ctx context.Context, owner, name, hash string) (*domain.Commit, []domain.FileDiff, error) {
220	repo, err := g.open(owner, name)
221	if err != nil {
222		return nil, nil, err
223	}
224	c, err := repo.CommitObject(plumbing.NewHash(hash))
225	if err != nil {
226		return nil, nil, mapErr(err)
227	}
228
229	thisTree, err := c.Tree()
230	if err != nil {
231		return nil, nil, mapErr(err)
232	}
233
234	var parentTree *object.Tree
235	if parent, err := c.Parents().Next(); err == nil {
236		parentTree, err = parent.Tree()
237		if err != nil {
238			return nil, nil, mapErr(err)
239		}
240	} else if !errors.Is(err, io.EOF) {
241		return nil, nil, mapErr(err)
242	}
243
244	changes, err := object.DiffTree(parentTree, thisTree)
245	if err != nil {
246		return nil, nil, mapErr(err)
247	}
248	patch, err := changes.Patch()
249	if err != nil {
250		return nil, nil, mapErr(err)
251	}
252
253	return commitDTO(c), fileDiffs(patch), nil
254}
255
256func (g *GoGitReader) Compare(ctx context.Context, owner, name, base, head string) (*domain.Comparison, error) {
257	repo, err := g.open(owner, name)
258	if err != nil {
259		return nil, err
260	}
261	baseCommit, err := g.resolveCommit(repo, base)
262	if err != nil {
263		return nil, err
264	}
265	headCommit, err := g.resolveCommit(repo, head)
266	if err != nil {
267		return nil, err
268	}
269
270	var mb *object.Commit
271	if bases, err := baseCommit.MergeBase(headCommit); err == nil && len(bases) > 0 {
272		mb = bases[0]
273	}
274
275	var ahead []domain.Commit
276	iter, err := repo.Log(&gogit.LogOptions{From: headCommit.Hash})
277	if err != nil {
278		return nil, mapErr(err)
279	}
280	err = iter.ForEach(func(c *object.Commit) error {
281		if mb != nil && c.Hash == mb.Hash {
282			return storerStop
283		}
284		ahead = append(ahead, *commitDTO(c))
285		return nil
286	})
287	iter.Close()
288	if err != nil && !errors.Is(err, storerStop) {
289		return nil, mapErr(err)
290	}
291
292	var baseTree *object.Tree
293	if mb != nil {
294		baseTree, err = mb.Tree()
295		if err != nil {
296			return nil, mapErr(err)
297		}
298	}
299	headTree, err := headCommit.Tree()
300	if err != nil {
301		return nil, mapErr(err)
302	}
303	changes, err := object.DiffTree(baseTree, headTree)
304	if err != nil {
305		return nil, mapErr(err)
306	}
307	patch, err := changes.Patch()
308	if err != nil {
309		return nil, mapErr(err)
310	}
311
312	return &domain.Comparison{
313		Commits:   ahead,
314		Files:     fileDiffs(patch),
315		Mergeable: len(ahead) > 0,
316	}, nil
317}
318
319func fileDiffs(patch *object.Patch) []domain.FileDiff {
320	var diffs []domain.FileDiff
321	for _, fp := range patch.FilePatches() {
322		from, to := fp.Files()
323		p := ""
324		if to != nil {
325			p = to.Path()
326		} else if from != nil {
327			p = from.Path()
328		}
329
330		var sb strings.Builder
331		added, deleted := 0, 0
332		for _, chunk := range fp.Chunks() {
333			lines := strings.SplitAfter(chunk.Content(), "\n")
334			for _, line := range lines {
335				if line == "" {
336					continue
337				}
338				switch chunk.Type() {
339				case 1:
340					sb.WriteString("+" + line)
341					added++
342				case 2:
343					sb.WriteString("-" + line)
344					deleted++
345				default:
346					sb.WriteString(" " + line)
347				}
348			}
349		}
350		diffs = append(diffs, domain.FileDiff{
351			Path:    p,
352			Patch:   sb.String(),
353			Added:   added,
354			Deleted: deleted,
355		})
356	}
357	return diffs
358}
359
360var storerStop = errors.New("stop")
361
362func commitDTO(c *object.Commit) *domain.Commit {
363	return &domain.Commit{
364		Hash:      c.Hash.String(),
365		ShortHash: c.Hash.String()[:7],
366		Message:   c.Message,
367		Author:    c.Author.Name,
368		Email:     c.Author.Email,
369		When:      c.Author.When,
370	}
371}
372
373func isBinary(content []byte) bool {
374	n := len(content)
375	if n > 8000 {
376		n = 8000
377	}
378	return bytes.IndexByte(content[:n], 0) >= 0
379}
380
381func mapErr(err error) error {
382	switch {
383	case errors.Is(err, gogit.ErrRepositoryNotExists),
384		errors.Is(err, plumbing.ErrReferenceNotFound),
385		errors.Is(err, plumbing.ErrObjectNotFound),
386		errors.Is(err, object.ErrFileNotFound),
387		errors.Is(err, object.ErrDirectoryNotFound),
388		errors.Is(err, object.ErrEntryNotFound):
389		return domain.ErrNotFound
390	default:
391		return err
392	}
393}