1package git
2
3import (
4 "archive/tar"
5 "fmt"
6 "io"
7 "io/fs"
8 "os/exec"
9 "path"
10 "sort"
11 "strconv"
12 "strings"
13 "time"
14
15 "github.com/go-git/go-git/v5"
16 "github.com/go-git/go-git/v5/plumbing"
17 "github.com/go-git/go-git/v5/plumbing/object"
18 "tangled.sh/tangled.sh/core/types"
19)
20
21var (
22 ErrBinaryFile = fmt.Errorf("binary file")
23 ErrNotBinaryFile = fmt.Errorf("not binary file")
24)
25
26type GitRepo struct {
27 path string
28 r *git.Repository
29 h plumbing.Hash
30}
31
32type TagList struct {
33 refs []*TagReference
34 r *git.Repository
35}
36
37// TagReference is used to list both tag and non-annotated tags.
38// Non-annotated tags should only contains a reference.
39// Annotated tags should contain its reference and its tag information.
40type TagReference struct {
41 ref *plumbing.Reference
42 tag *object.Tag
43}
44
45// infoWrapper wraps the property of a TreeEntry so it can export fs.FileInfo
46// to tar WriteHeader
47type infoWrapper struct {
48 name string
49 size int64
50 mode fs.FileMode
51 modTime time.Time
52 isDir bool
53}
54
55func (self *TagList) Len() int {
56 return len(self.refs)
57}
58
59func (self *TagList) Swap(i, j int) {
60 self.refs[i], self.refs[j] = self.refs[j], self.refs[i]
61}
62
63// sorting tags in reverse chronological order
64func (self *TagList) Less(i, j int) bool {
65 var dateI time.Time
66 var dateJ time.Time
67
68 if self.refs[i].tag != nil {
69 dateI = self.refs[i].tag.Tagger.When
70 } else {
71 c, err := self.r.CommitObject(self.refs[i].ref.Hash())
72 if err != nil {
73 dateI = time.Now()
74 } else {
75 dateI = c.Committer.When
76 }
77 }
78
79 if self.refs[j].tag != nil {
80 dateJ = self.refs[j].tag.Tagger.When
81 } else {
82 c, err := self.r.CommitObject(self.refs[j].ref.Hash())
83 if err != nil {
84 dateJ = time.Now()
85 } else {
86 dateJ = c.Committer.When
87 }
88 }
89
90 return dateI.After(dateJ)
91}
92
93func Open(path string, ref string) (*GitRepo, error) {
94 var err error
95 g := GitRepo{path: path}
96 g.r, err = git.PlainOpen(path)
97 if err != nil {
98 return nil, fmt.Errorf("opening %s: %w", path, err)
99 }
100
101 if ref == "" {
102 head, err := g.r.Head()
103 if err != nil {
104 return nil, fmt.Errorf("getting head of %s: %w", path, err)
105 }
106 g.h = head.Hash()
107 } else {
108 hash, err := g.r.ResolveRevision(plumbing.Revision(ref))
109 if err != nil {
110 return nil, fmt.Errorf("resolving rev %s for %s: %w", ref, path, err)
111 }
112 g.h = *hash
113 }
114 return &g, nil
115}
116
117func PlainOpen(path string) (*GitRepo, error) {
118 var err error
119 g := GitRepo{path: path}
120 g.r, err = git.PlainOpen(path)
121 if err != nil {
122 return nil, fmt.Errorf("opening %s: %w", path, err)
123 }
124 return &g, nil
125}
126
127func (g *GitRepo) Commits(offset, limit int) ([]*object.Commit, error) {
128 commits := []*object.Commit{}
129
130 output, err := g.revList(
131 g.h.String(),
132 fmt.Sprintf("--skip=%d", offset),
133 fmt.Sprintf("--max-count=%d", limit),
134 )
135 if err != nil {
136 return nil, fmt.Errorf("commits from ref: %w", err)
137 }
138
139 lines := strings.Split(strings.TrimSpace(string(output)), "\n")
140 if len(lines) == 1 && lines[0] == "" {
141 return commits, nil
142 }
143
144 for _, item := range lines {
145 obj, err := g.r.CommitObject(plumbing.NewHash(item))
146 if err != nil {
147 continue
148 }
149 commits = append(commits, obj)
150 }
151
152 return commits, nil
153}
154
155func (g *GitRepo) TotalCommits() (int, error) {
156 output, err := g.revList(
157 g.h.String(),
158 fmt.Sprintf("--count"),
159 )
160 if err != nil {
161 return 0, fmt.Errorf("failed to run rev-list", err)
162 }
163
164 count, err := strconv.Atoi(strings.TrimSpace(string(output)))
165 if err != nil {
166 return 0, err
167 }
168
169 return count, nil
170}
171
172func (g *GitRepo) revList(extraArgs ...string) ([]byte, error) {
173 var args []string
174 args = append(args, "rev-list")
175 args = append(args, extraArgs...)
176
177 cmd := exec.Command("git", args...)
178 cmd.Dir = g.path
179
180 out, err := cmd.Output()
181 if err != nil {
182 if exitErr, ok := err.(*exec.ExitError); ok {
183 return nil, fmt.Errorf("%w, stderr: %s", err, string(exitErr.Stderr))
184 }
185 return nil, err
186 }
187
188 return out, nil
189}
190
191func (g *GitRepo) Commit(h plumbing.Hash) (*object.Commit, error) {
192 return g.r.CommitObject(h)
193}
194
195func (g *GitRepo) LastCommit() (*object.Commit, error) {
196 c, err := g.r.CommitObject(g.h)
197 if err != nil {
198 return nil, fmt.Errorf("last commit: %w", err)
199 }
200 return c, nil
201}
202
203func (g *GitRepo) FileContentN(path string, cap int64) ([]byte, error) {
204 buf := []byte{}
205
206 c, err := g.r.CommitObject(g.h)
207 if err != nil {
208 return nil, fmt.Errorf("commit object: %w", err)
209 }
210
211 tree, err := c.Tree()
212 if err != nil {
213 return nil, fmt.Errorf("file tree: %w", err)
214 }
215
216 file, err := tree.File(path)
217 if err != nil {
218 return nil, err
219 }
220
221 isbin, _ := file.IsBinary()
222
223 if !isbin {
224 reader, err := file.Reader()
225 if err != nil {
226 return nil, err
227 }
228 bufReader := io.LimitReader(reader, cap)
229 _, err = bufReader.Read(buf)
230 if err != nil {
231 return nil, err
232 }
233 return buf, nil
234 } else {
235 return nil, ErrBinaryFile
236 }
237}
238
239func (g *GitRepo) FileContent(path string) (string, error) {
240 c, err := g.r.CommitObject(g.h)
241 if err != nil {
242 return "", fmt.Errorf("commit object: %w", err)
243 }
244
245 tree, err := c.Tree()
246 if err != nil {
247 return "", fmt.Errorf("file tree: %w", err)
248 }
249
250 file, err := tree.File(path)
251 if err != nil {
252 return "", err
253 }
254
255 isbin, _ := file.IsBinary()
256
257 if !isbin {
258 return file.Contents()
259 } else {
260 return "", ErrBinaryFile
261 }
262}
263
264func (g *GitRepo) RawContent(path string) ([]byte, error) {
265 c, err := g.r.CommitObject(g.h)
266 if err != nil {
267 return nil, fmt.Errorf("commit object: %w", err)
268 }
269
270 tree, err := c.Tree()
271 if err != nil {
272 return nil, fmt.Errorf("file tree: %w", err)
273 }
274
275 file, err := tree.File(path)
276 if err != nil {
277 return nil, err
278 }
279
280 reader, err := file.Reader()
281 if err != nil {
282 return nil, fmt.Errorf("opening file reader: %w", err)
283 }
284 defer reader.Close()
285
286 return io.ReadAll(reader)
287}
288
289func (g *GitRepo) Tags() ([]*TagReference, error) {
290 iter, err := g.r.Tags()
291 if err != nil {
292 return nil, fmt.Errorf("tag objects: %w", err)
293 }
294
295 tags := make([]*TagReference, 0)
296
297 if err := iter.ForEach(func(ref *plumbing.Reference) error {
298 obj, err := g.r.TagObject(ref.Hash())
299 switch err {
300 case nil:
301 tags = append(tags, &TagReference{
302 ref: ref,
303 tag: obj,
304 })
305 case plumbing.ErrObjectNotFound:
306 tags = append(tags, &TagReference{
307 ref: ref,
308 })
309 default:
310 return err
311 }
312 return nil
313 }); err != nil {
314 return nil, err
315 }
316
317 tagList := &TagList{r: g.r, refs: tags}
318 sort.Sort(tagList)
319 return tags, nil
320}
321
322func (g *GitRepo) Branches() ([]types.Branch, error) {
323 bi, err := g.r.Branches()
324 if err != nil {
325 return nil, fmt.Errorf("branchs: %w", err)
326 }
327
328 branches := []types.Branch{}
329
330 defaultBranch, err := g.FindMainBranch()
331
332 _ = bi.ForEach(func(ref *plumbing.Reference) error {
333 b := types.Branch{}
334 b.Hash = ref.Hash().String()
335 b.Name = ref.Name().Short()
336
337 // resolve commit that this branch points to
338 commit, _ := g.Commit(ref.Hash())
339 if commit != nil {
340 b.Commit = commit
341 }
342
343 if defaultBranch != "" && defaultBranch == b.Name {
344 b.IsDefault = true
345 }
346
347 branches = append(branches, b)
348
349 return nil
350 })
351
352 return branches, nil
353}
354
355func (g *GitRepo) Branch(name string) (*plumbing.Reference, error) {
356 ref, err := g.r.Reference(plumbing.NewBranchReferenceName(name), false)
357 if err != nil {
358 return nil, fmt.Errorf("branch: %w", err)
359 }
360
361 if !ref.Name().IsBranch() {
362 return nil, fmt.Errorf("branch: %s is not a branch", ref.Name())
363 }
364
365 return ref, nil
366}
367
368func (g *GitRepo) SetDefaultBranch(branch string) error {
369 ref := plumbing.NewSymbolicReference(plumbing.HEAD, plumbing.NewBranchReferenceName(branch))
370 return g.r.Storer.SetReference(ref)
371}
372
373func (g *GitRepo) FindMainBranch() (string, error) {
374 ref, err := g.r.Head()
375 if err != nil {
376 return "", fmt.Errorf("unable to find main branch: %w", err)
377 }
378 if ref.Name().IsBranch() {
379 return strings.TrimPrefix(string(ref.Name()), "refs/heads/"), nil
380 }
381
382 return "", fmt.Errorf("unable to find main branch: %w", err)
383}
384
385// WriteTar writes itself from a tree into a binary tar file format.
386// prefix is root folder to be appended.
387func (g *GitRepo) WriteTar(w io.Writer, prefix string) error {
388 tw := tar.NewWriter(w)
389 defer tw.Close()
390
391 c, err := g.r.CommitObject(g.h)
392 if err != nil {
393 return fmt.Errorf("commit object: %w", err)
394 }
395
396 tree, err := c.Tree()
397 if err != nil {
398 return err
399 }
400
401 walker := object.NewTreeWalker(tree, true, nil)
402 defer walker.Close()
403
404 name, entry, err := walker.Next()
405 for ; err == nil; name, entry, err = walker.Next() {
406 info, err := newInfoWrapper(name, prefix, &entry, tree)
407 if err != nil {
408 return err
409 }
410
411 header, err := tar.FileInfoHeader(info, "")
412 if err != nil {
413 return err
414 }
415
416 err = tw.WriteHeader(header)
417 if err != nil {
418 return err
419 }
420
421 if !info.IsDir() {
422 file, err := tree.File(name)
423 if err != nil {
424 return err
425 }
426
427 reader, err := file.Blob.Reader()
428 if err != nil {
429 return err
430 }
431
432 _, err = io.Copy(tw, reader)
433 if err != nil {
434 reader.Close()
435 return err
436 }
437 reader.Close()
438 }
439 }
440
441 return nil
442}
443
444func newInfoWrapper(
445 name string,
446 prefix string,
447 entry *object.TreeEntry,
448 tree *object.Tree,
449) (*infoWrapper, error) {
450 var (
451 size int64
452 mode fs.FileMode
453 isDir bool
454 )
455
456 if entry.Mode.IsFile() {
457 file, err := tree.TreeEntryFile(entry)
458 if err != nil {
459 return nil, err
460 }
461 mode = fs.FileMode(file.Mode)
462
463 size, err = tree.Size(name)
464 if err != nil {
465 return nil, err
466 }
467 } else {
468 isDir = true
469 mode = fs.ModeDir | fs.ModePerm
470 }
471
472 fullname := path.Join(prefix, name)
473 return &infoWrapper{
474 name: fullname,
475 size: size,
476 mode: mode,
477 modTime: time.Unix(0, 0),
478 isDir: isDir,
479 }, nil
480}
481
482func (i *infoWrapper) Name() string {
483 return i.name
484}
485
486func (i *infoWrapper) Size() int64 {
487 return i.size
488}
489
490func (i *infoWrapper) Mode() fs.FileMode {
491 return i.mode
492}
493
494func (i *infoWrapper) ModTime() time.Time {
495 return i.modTime
496}
497
498func (i *infoWrapper) IsDir() bool {
499 return i.isDir
500}
501
502func (i *infoWrapper) Sys() any {
503 return nil
504}
505
506func (t *TagReference) Name() string {
507 return t.ref.Name().Short()
508}
509
510func (t *TagReference) Message() string {
511 if t.tag != nil {
512 return t.tag.Message
513 }
514 return ""
515}
516
517func (t *TagReference) TagObject() *object.Tag {
518 return t.tag
519}
520
521func (t *TagReference) Hash() plumbing.Hash {
522 return t.ref.Hash()
523}