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) LastCommit() (*object.Commit, error) {
160 c, err := g.r.CommitObject(g.h)
161 if err != nil {
162 return nil, fmt.Errorf("last commit: %w", err)
163 }
164 return c, nil
165}
166
167func (g *GitRepo) FileContent(path string) (string, error) {
168 c, err := g.r.CommitObject(g.h)
169 if err != nil {
170 return "", fmt.Errorf("commit object: %w", err)
171 }
172
173 tree, err := c.Tree()
174 if err != nil {
175 return "", fmt.Errorf("file tree: %w", err)
176 }
177
178 file, err := tree.File(path)
179 if err != nil {
180 return "", err
181 }
182
183 isbin, _ := file.IsBinary()
184
185 if !isbin {
186 return file.Contents()
187 } else {
188 return "", ErrBinaryFile
189 }
190}
191
192func (g *GitRepo) Tags() ([]*TagReference, error) {
193 iter, err := g.r.Tags()
194 if err != nil {
195 return nil, fmt.Errorf("tag objects: %w", err)
196 }
197
198 tags := make([]*TagReference, 0)
199
200 if err := iter.ForEach(func(ref *plumbing.Reference) error {
201 obj, err := g.r.TagObject(ref.Hash())
202 switch err {
203 case nil:
204 tags = append(tags, &TagReference{
205 ref: ref,
206 tag: obj,
207 })
208 case plumbing.ErrObjectNotFound:
209 tags = append(tags, &TagReference{
210 ref: ref,
211 })
212 default:
213 return err
214 }
215 return nil
216 }); err != nil {
217 return nil, err
218 }
219
220 tagList := &TagList{r: g.r, refs: tags}
221 sort.Sort(tagList)
222 return tags, nil
223}
224
225func (g *GitRepo) Branches() ([]*plumbing.Reference, error) {
226 bi, err := g.r.Branches()
227 if err != nil {
228 return nil, fmt.Errorf("branchs: %w", err)
229 }
230
231 branches := []*plumbing.Reference{}
232
233 _ = bi.ForEach(func(ref *plumbing.Reference) error {
234 branches = append(branches, ref)
235 return nil
236 })
237
238 return branches, nil
239}
240
241func (g *GitRepo) Branch(name string) (*plumbing.Reference, error) {
242 ref, err := g.r.Reference(plumbing.NewBranchReferenceName(name), false)
243 if err != nil {
244 return nil, fmt.Errorf("branch: %w", err)
245 }
246
247 if !ref.Name().IsBranch() {
248 return nil, fmt.Errorf("branch: %s is not a branch", ref.Name())
249 }
250
251 return ref, nil
252}
253
254func (g *GitRepo) SetDefaultBranch(branch string) error {
255 ref := plumbing.NewSymbolicReference(plumbing.HEAD, plumbing.NewBranchReferenceName(branch))
256 return g.r.Storer.SetReference(ref)
257}
258
259func (g *GitRepo) FindMainBranch() (string, error) {
260 ref, err := g.r.Head()
261 if err != nil {
262 return "", fmt.Errorf("unable to find main branch: %w", err)
263 }
264 if ref.Name().IsBranch() {
265 return strings.TrimPrefix(string(ref.Name()), "refs/heads/"), nil
266 }
267
268 return "", fmt.Errorf("unable to find main branch: %w", err)
269}
270
271// WriteTar writes itself from a tree into a binary tar file format.
272// prefix is root folder to be appended.
273func (g *GitRepo) WriteTar(w io.Writer, prefix string) error {
274 tw := tar.NewWriter(w)
275 defer tw.Close()
276
277 c, err := g.r.CommitObject(g.h)
278 if err != nil {
279 return fmt.Errorf("commit object: %w", err)
280 }
281
282 tree, err := c.Tree()
283 if err != nil {
284 return err
285 }
286
287 walker := object.NewTreeWalker(tree, true, nil)
288 defer walker.Close()
289
290 name, entry, err := walker.Next()
291 for ; err == nil; name, entry, err = walker.Next() {
292 info, err := newInfoWrapper(name, prefix, &entry, tree)
293 if err != nil {
294 return err
295 }
296
297 header, err := tar.FileInfoHeader(info, "")
298 if err != nil {
299 return err
300 }
301
302 err = tw.WriteHeader(header)
303 if err != nil {
304 return err
305 }
306
307 if !info.IsDir() {
308 file, err := tree.File(name)
309 if err != nil {
310 return err
311 }
312
313 reader, err := file.Blob.Reader()
314 if err != nil {
315 return err
316 }
317
318 _, err = io.Copy(tw, reader)
319 if err != nil {
320 reader.Close()
321 return err
322 }
323 reader.Close()
324 }
325 }
326
327 return nil
328}
329
330func (g *GitRepo) LastCommitForPath(path string) (*types.LastCommitInfo, error) {
331 cacheKey := fmt.Sprintf("%s:%s", g.h.String(), path)
332 cacheMu.RLock()
333 if commitInfo, found := commitCache.Get(cacheKey); found {
334 cacheMu.RUnlock()
335 return commitInfo.(*types.LastCommitInfo), nil
336 }
337 cacheMu.RUnlock()
338
339 cmd := exec.Command("git", "-C", g.path, "log", g.h.String(), "-1", "--format=%H %ct", "--", path)
340
341 var out bytes.Buffer
342 cmd.Stdout = &out
343 cmd.Stderr = &out
344
345 if err := cmd.Run(); err != nil {
346 return nil, fmt.Errorf("failed to get commit hash: %w", err)
347 }
348
349 output := strings.TrimSpace(out.String())
350 if output == "" {
351 return nil, fmt.Errorf("no commits found for path: %s", path)
352 }
353
354 parts := strings.SplitN(output, " ", 2)
355 if len(parts) < 2 {
356 return nil, fmt.Errorf("unexpected commit log format")
357 }
358
359 commitHash := parts[0]
360 commitTimeUnix, err := strconv.ParseInt(parts[1], 10, 64)
361 if err != nil {
362 return nil, fmt.Errorf("parsing commit time: %w", err)
363 }
364 commitTime := time.Unix(commitTimeUnix, 0)
365
366 hash := plumbing.NewHash(commitHash)
367
368 commitInfo := &types.LastCommitInfo{
369 Hash: hash,
370 Message: "",
371 When: commitTime,
372 }
373
374 cacheMu.Lock()
375 commitCache.Set(cacheKey, commitInfo, 1)
376 cacheMu.Unlock()
377
378 return commitInfo, nil
379}
380
381func newInfoWrapper(
382 name string,
383 prefix string,
384 entry *object.TreeEntry,
385 tree *object.Tree,
386) (*infoWrapper, error) {
387 var (
388 size int64
389 mode fs.FileMode
390 isDir bool
391 )
392
393 if entry.Mode.IsFile() {
394 file, err := tree.TreeEntryFile(entry)
395 if err != nil {
396 return nil, err
397 }
398 mode = fs.FileMode(file.Mode)
399
400 size, err = tree.Size(name)
401 if err != nil {
402 return nil, err
403 }
404 } else {
405 isDir = true
406 mode = fs.ModeDir | fs.ModePerm
407 }
408
409 fullname := path.Join(prefix, name)
410 return &infoWrapper{
411 name: fullname,
412 size: size,
413 mode: mode,
414 modTime: time.Unix(0, 0),
415 isDir: isDir,
416 }, nil
417}
418
419func (i *infoWrapper) Name() string {
420 return i.name
421}
422
423func (i *infoWrapper) Size() int64 {
424 return i.size
425}
426
427func (i *infoWrapper) Mode() fs.FileMode {
428 return i.mode
429}
430
431func (i *infoWrapper) ModTime() time.Time {
432 return i.modTime
433}
434
435func (i *infoWrapper) IsDir() bool {
436 return i.isDir
437}
438
439func (i *infoWrapper) Sys() any {
440 return nil
441}
442
443func (t *TagReference) Name() string {
444 return t.ref.Name().Short()
445}
446
447func (t *TagReference) Message() string {
448 if t.tag != nil {
449 return t.tag.Message
450 }
451 return ""
452}
453
454func (t *TagReference) TagObject() *object.Tag {
455 return t.tag
456}
457
458func (t *TagReference) Hash() plumbing.Hash {
459 return t.ref.Hash()
460}