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}