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 fmt.Sprintf("--skip=%d", offset),
132 fmt.Sprintf("--max-count=%d", limit),
133 )
134 if err != nil {
135 return nil, fmt.Errorf("commits from ref: %w", err)
136 }
137
138 lines := strings.Split(strings.TrimSpace(string(output)), "\n")
139 if len(lines) == 1 && lines[0] == "" {
140 return commits, nil
141 }
142
143 for _, item := range lines {
144 obj, err := g.r.CommitObject(plumbing.NewHash(item))
145 if err != nil {
146 continue
147 }
148 commits = append(commits, obj)
149 }
150
151 return commits, nil
152}
153
154func (g *GitRepo) TotalCommits() (int, error) {
155 output, err := g.revList(
156 fmt.Sprintf("--count"),
157 )
158 if err != nil {
159 return 0, fmt.Errorf("failed to run rev-list", err)
160 }
161
162 count, err := strconv.Atoi(strings.TrimSpace(string(output)))
163 if err != nil {
164 return 0, err
165 }
166
167 return count, nil
168}
169
170func (g *GitRepo) revList(extraArgs ...string) ([]byte, error) {
171 var args []string
172 args = append(args, "rev-list")
173 args = append(args, g.h.String())
174 args = append(args, extraArgs...)
175
176 cmd := exec.Command("git", args...)
177 cmd.Dir = g.path
178
179 return cmd.Output()
180}
181
182func (g *GitRepo) Commit(h plumbing.Hash) (*object.Commit, error) {
183 return g.r.CommitObject(h)
184}
185
186func (g *GitRepo) LastCommit() (*object.Commit, error) {
187 c, err := g.r.CommitObject(g.h)
188 if err != nil {
189 return nil, fmt.Errorf("last commit: %w", err)
190 }
191 return c, nil
192}
193
194func (g *GitRepo) FileContentN(path string, cap int64) ([]byte, error) {
195 buf := []byte{}
196
197 c, err := g.r.CommitObject(g.h)
198 if err != nil {
199 return nil, fmt.Errorf("commit object: %w", err)
200 }
201
202 tree, err := c.Tree()
203 if err != nil {
204 return nil, fmt.Errorf("file tree: %w", err)
205 }
206
207 file, err := tree.File(path)
208 if err != nil {
209 return nil, err
210 }
211
212 isbin, _ := file.IsBinary()
213
214 if !isbin {
215 reader, err := file.Reader()
216 if err != nil {
217 return nil, err
218 }
219 bufReader := io.LimitReader(reader, cap)
220 _, err = bufReader.Read(buf)
221 if err != nil {
222 return nil, err
223 }
224 return buf, nil
225 } else {
226 return nil, ErrBinaryFile
227 }
228}
229
230func (g *GitRepo) FileContent(path string) (string, error) {
231 c, err := g.r.CommitObject(g.h)
232 if err != nil {
233 return "", fmt.Errorf("commit object: %w", err)
234 }
235
236 tree, err := c.Tree()
237 if err != nil {
238 return "", fmt.Errorf("file tree: %w", err)
239 }
240
241 file, err := tree.File(path)
242 if err != nil {
243 return "", err
244 }
245
246 isbin, _ := file.IsBinary()
247
248 if !isbin {
249 return file.Contents()
250 } else {
251 return "", ErrBinaryFile
252 }
253}
254
255func (g *GitRepo) RawContent(path string) ([]byte, error) {
256 c, err := g.r.CommitObject(g.h)
257 if err != nil {
258 return nil, fmt.Errorf("commit object: %w", err)
259 }
260
261 tree, err := c.Tree()
262 if err != nil {
263 return nil, fmt.Errorf("file tree: %w", err)
264 }
265
266 file, err := tree.File(path)
267 if err != nil {
268 return nil, err
269 }
270
271 reader, err := file.Reader()
272 if err != nil {
273 return nil, fmt.Errorf("opening file reader: %w", err)
274 }
275 defer reader.Close()
276
277 return io.ReadAll(reader)
278}
279
280func (g *GitRepo) Tags() ([]*TagReference, error) {
281 iter, err := g.r.Tags()
282 if err != nil {
283 return nil, fmt.Errorf("tag objects: %w", err)
284 }
285
286 tags := make([]*TagReference, 0)
287
288 if err := iter.ForEach(func(ref *plumbing.Reference) error {
289 obj, err := g.r.TagObject(ref.Hash())
290 switch err {
291 case nil:
292 tags = append(tags, &TagReference{
293 ref: ref,
294 tag: obj,
295 })
296 case plumbing.ErrObjectNotFound:
297 tags = append(tags, &TagReference{
298 ref: ref,
299 })
300 default:
301 return err
302 }
303 return nil
304 }); err != nil {
305 return nil, err
306 }
307
308 tagList := &TagList{r: g.r, refs: tags}
309 sort.Sort(tagList)
310 return tags, nil
311}
312
313func (g *GitRepo) Branches() ([]types.Branch, error) {
314 bi, err := g.r.Branches()
315 if err != nil {
316 return nil, fmt.Errorf("branchs: %w", err)
317 }
318
319 branches := []types.Branch{}
320
321 defaultBranch, err := g.FindMainBranch()
322
323 _ = bi.ForEach(func(ref *plumbing.Reference) error {
324 b := types.Branch{}
325 b.Hash = ref.Hash().String()
326 b.Name = ref.Name().Short()
327
328 // resolve commit that this branch points to
329 commit, _ := g.Commit(ref.Hash())
330 if commit != nil {
331 b.Commit = commit
332 }
333
334 if defaultBranch != "" && defaultBranch == b.Name {
335 b.IsDefault = true
336 }
337
338 branches = append(branches, b)
339
340 return nil
341 })
342
343 return branches, nil
344}
345
346func (g *GitRepo) Branch(name string) (*plumbing.Reference, error) {
347 ref, err := g.r.Reference(plumbing.NewBranchReferenceName(name), false)
348 if err != nil {
349 return nil, fmt.Errorf("branch: %w", err)
350 }
351
352 if !ref.Name().IsBranch() {
353 return nil, fmt.Errorf("branch: %s is not a branch", ref.Name())
354 }
355
356 return ref, nil
357}
358
359func (g *GitRepo) SetDefaultBranch(branch string) error {
360 ref := plumbing.NewSymbolicReference(plumbing.HEAD, plumbing.NewBranchReferenceName(branch))
361 return g.r.Storer.SetReference(ref)
362}
363
364func (g *GitRepo) FindMainBranch() (string, error) {
365 ref, err := g.r.Head()
366 if err != nil {
367 return "", fmt.Errorf("unable to find main branch: %w", err)
368 }
369 if ref.Name().IsBranch() {
370 return strings.TrimPrefix(string(ref.Name()), "refs/heads/"), nil
371 }
372
373 return "", fmt.Errorf("unable to find main branch: %w", err)
374}
375
376// WriteTar writes itself from a tree into a binary tar file format.
377// prefix is root folder to be appended.
378func (g *GitRepo) WriteTar(w io.Writer, prefix string) error {
379 tw := tar.NewWriter(w)
380 defer tw.Close()
381
382 c, err := g.r.CommitObject(g.h)
383 if err != nil {
384 return fmt.Errorf("commit object: %w", err)
385 }
386
387 tree, err := c.Tree()
388 if err != nil {
389 return err
390 }
391
392 walker := object.NewTreeWalker(tree, true, nil)
393 defer walker.Close()
394
395 name, entry, err := walker.Next()
396 for ; err == nil; name, entry, err = walker.Next() {
397 info, err := newInfoWrapper(name, prefix, &entry, tree)
398 if err != nil {
399 return err
400 }
401
402 header, err := tar.FileInfoHeader(info, "")
403 if err != nil {
404 return err
405 }
406
407 err = tw.WriteHeader(header)
408 if err != nil {
409 return err
410 }
411
412 if !info.IsDir() {
413 file, err := tree.File(name)
414 if err != nil {
415 return err
416 }
417
418 reader, err := file.Blob.Reader()
419 if err != nil {
420 return err
421 }
422
423 _, err = io.Copy(tw, reader)
424 if err != nil {
425 reader.Close()
426 return err
427 }
428 reader.Close()
429 }
430 }
431
432 return nil
433}
434
435func newInfoWrapper(
436 name string,
437 prefix string,
438 entry *object.TreeEntry,
439 tree *object.Tree,
440) (*infoWrapper, error) {
441 var (
442 size int64
443 mode fs.FileMode
444 isDir bool
445 )
446
447 if entry.Mode.IsFile() {
448 file, err := tree.TreeEntryFile(entry)
449 if err != nil {
450 return nil, err
451 }
452 mode = fs.FileMode(file.Mode)
453
454 size, err = tree.Size(name)
455 if err != nil {
456 return nil, err
457 }
458 } else {
459 isDir = true
460 mode = fs.ModeDir | fs.ModePerm
461 }
462
463 fullname := path.Join(prefix, name)
464 return &infoWrapper{
465 name: fullname,
466 size: size,
467 mode: mode,
468 modTime: time.Unix(0, 0),
469 isDir: isDir,
470 }, nil
471}
472
473func (i *infoWrapper) Name() string {
474 return i.name
475}
476
477func (i *infoWrapper) Size() int64 {
478 return i.size
479}
480
481func (i *infoWrapper) Mode() fs.FileMode {
482 return i.mode
483}
484
485func (i *infoWrapper) ModTime() time.Time {
486 return i.modTime
487}
488
489func (i *infoWrapper) IsDir() bool {
490 return i.isDir
491}
492
493func (i *infoWrapper) Sys() any {
494 return nil
495}
496
497func (t *TagReference) Name() string {
498 return t.ref.Name().Short()
499}
500
501func (t *TagReference) Message() string {
502 if t.tag != nil {
503 return t.tag.Message
504 }
505 return ""
506}
507
508func (t *TagReference) TagObject() *object.Tag {
509 return t.tag
510}
511
512func (t *TagReference) Hash() plumbing.Hash {
513 return t.ref.Hash()
514}