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