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