1package git
2
3import (
4 "errors"
5 "log"
6 "os"
7 "path/filepath"
8 "sort"
9 "sync"
10 "time"
11
12 gitypes "github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/types"
13 "github.com/go-git/go-billy/v5/memfs"
14 "github.com/go-git/go-git/v5"
15 "github.com/go-git/go-git/v5/plumbing"
16 "github.com/go-git/go-git/v5/plumbing/object"
17 "github.com/go-git/go-git/v5/plumbing/transport"
18 "github.com/go-git/go-git/v5/storage/memory"
19)
20
21// ErrMissingRepo indicates that the requested repository could not be found.
22var ErrMissingRepo = errors.New("missing repo")
23
24// Repo represents a Git repository.
25type Repo struct {
26 Name string
27 Repository *git.Repository
28 Readme string
29 LastUpdated *time.Time
30 refCommits map[plumbing.Hash]gitypes.Commits
31 ref *plumbing.Reference
32 refs []*plumbing.Reference
33}
34
35// GetName returns the name of the repository.
36func (r *Repo) GetName() string {
37 return r.Name
38}
39
40// GetHEAD returns the reference for a repository.
41func (r *Repo) GetHEAD() *plumbing.Reference {
42 return r.ref
43}
44
45// SetHEAD sets the repository head reference.
46func (r *Repo) SetHEAD(ref *plumbing.Reference) error {
47 r.ref = ref
48 return nil
49}
50
51func (r *Repo) GetReferences() []*plumbing.Reference {
52 return r.refs
53}
54
55// GetRepository returns the underlying go-git repository object.
56func (r *Repo) GetRepository() *git.Repository {
57 return r.Repository
58}
59
60// Tree returns the git tree for a given path.
61func (r *Repo) Tree(ref *plumbing.Reference, path string) (*object.Tree, error) {
62 path = filepath.Clean(path)
63 hash, err := r.targetHash(ref)
64 if err != nil {
65 return nil, err
66 }
67 c, err := r.Repository.CommitObject(hash)
68 if err != nil {
69 return nil, err
70 }
71 t, err := c.Tree()
72 if err != nil {
73 return nil, err
74 }
75 if path == "." {
76 return t, nil
77 }
78 return t.Tree(path)
79}
80
81// GetCommits returns the commits for a repository.
82func (r *Repo) GetCommits(ref *plumbing.Reference) (gitypes.Commits, error) {
83 hash, err := r.targetHash(ref)
84 if err != nil {
85 return nil, err
86 }
87 // return cached commits if available
88 commits, ok := r.refCommits[hash]
89 if ok {
90 return commits, nil
91 }
92 log.Printf("caching commits for %s/%s: %s", r.Name, ref.Name(), ref.Hash())
93 commits = gitypes.Commits{}
94 co, err := r.Repository.CommitObject(hash)
95 if err != nil {
96 return nil, err
97 }
98 // traverse the commit tree to get all commits
99 commits = append(commits, &gitypes.Commit{Commit: co})
100 for {
101 co, err = co.Parent(0)
102 if err != nil {
103 if err == object.ErrParentNotFound {
104 err = nil
105 }
106 break
107 }
108 commits = append(commits, &gitypes.Commit{Commit: co})
109 }
110 if err != nil {
111 return nil, err
112 }
113 sort.Sort(commits)
114 // cache the commits in the repo
115 r.refCommits[hash] = commits
116 return commits, nil
117}
118
119// targetHash returns the target hash for a given reference. If reference is an
120// annotated tag, find the target hash for that tag.
121func (r *Repo) targetHash(ref *plumbing.Reference) (plumbing.Hash, error) {
122 hash := ref.Hash()
123 if ref.Type() != plumbing.HashReference {
124 return plumbing.ZeroHash, plumbing.ErrInvalidType
125 }
126 if ref.Name().IsTag() {
127 to, err := r.Repository.TagObject(hash)
128 switch err {
129 case nil:
130 // annotated tag (object has a target hash)
131 hash = to.Target
132 case plumbing.ErrObjectNotFound:
133 // lightweight tag (hash points to a commit)
134 default:
135 return plumbing.ZeroHash, err
136 }
137 }
138 return hash, nil
139}
140
141// loadCommits loads the commits for a repository.
142func (r *Repo) loadCommits(ref *plumbing.Reference) (gitypes.Commits, error) {
143 commits := gitypes.Commits{}
144 hash, err := r.targetHash(ref)
145 if err != nil {
146 return nil, err
147 }
148 l, err := r.Repository.Log(&git.LogOptions{
149 Order: git.LogOrderCommitterTime,
150 From: hash,
151 })
152 if err != nil {
153 return nil, err
154 }
155 defer l.Close()
156 err = l.ForEach(func(c *object.Commit) error {
157 commits = append(commits, &gitypes.Commit{Commit: c})
158 return nil
159 })
160 if err != nil {
161 return nil, err
162 }
163 return commits, nil
164}
165
166// GetReadme returns the readme for a repository.
167func (r *Repo) GetReadme() string {
168 if r.Readme != "" {
169 return r.Readme
170 }
171 md, err := r.LatestFile("README.md")
172 if err != nil {
173 return ""
174 }
175 return md
176}
177
178// RepoSource is a reference to an on-disk repositories.
179type RepoSource struct {
180 Path string
181 mtx sync.Mutex
182 repos []*Repo
183}
184
185// NewRepoSource creates a new RepoSource.
186func NewRepoSource(repoPath string) *RepoSource {
187 err := os.MkdirAll(repoPath, os.ModeDir|os.FileMode(0700))
188 if err != nil {
189 log.Fatal(err)
190 }
191 rs := &RepoSource{Path: repoPath}
192 return rs
193}
194
195// AllRepos returns all repositories for the given RepoSource.
196func (rs *RepoSource) AllRepos() []*Repo {
197 rs.mtx.Lock()
198 defer rs.mtx.Unlock()
199 return rs.repos
200}
201
202// GetRepo returns a repository by name.
203func (rs *RepoSource) GetRepo(name string) (*Repo, error) {
204 rs.mtx.Lock()
205 defer rs.mtx.Unlock()
206 for _, r := range rs.repos {
207 if r.Name == name {
208 return r, nil
209 }
210 }
211 return nil, ErrMissingRepo
212}
213
214// InitRepo initializes a new Git repository.
215func (rs *RepoSource) InitRepo(name string, bare bool) (*Repo, error) {
216 rs.mtx.Lock()
217 defer rs.mtx.Unlock()
218 rp := filepath.Join(rs.Path, name)
219 rg, err := git.PlainInit(rp, bare)
220 if err != nil {
221 return nil, err
222 }
223 if bare {
224 // Clone repo into memory storage
225 ar, err := git.Clone(memory.NewStorage(), memfs.New(), &git.CloneOptions{
226 URL: rp,
227 })
228 if err != nil && err != transport.ErrEmptyRemoteRepository {
229 return nil, err
230 }
231 rg = ar
232 }
233 r := &Repo{
234 Name: name,
235 Repository: rg,
236 }
237 rs.repos = append(rs.repos, r)
238 return r, nil
239}
240
241// LoadRepos opens Git repositories.
242func (rs *RepoSource) LoadRepos() error {
243 rs.mtx.Lock()
244 defer rs.mtx.Unlock()
245 rd, err := os.ReadDir(rs.Path)
246 if err != nil {
247 return err
248 }
249 rs.repos = make([]*Repo, 0)
250 for _, de := range rd {
251 rn := de.Name()
252 rg, err := git.PlainOpen(filepath.Join(rs.Path, rn))
253 if err != nil {
254 return err
255 }
256 r, err := rs.loadRepo(rn, rg)
257 if err != nil {
258 return err
259 }
260 rs.repos = append(rs.repos, r)
261 }
262 return nil
263}
264
265func (rs *RepoSource) loadRepo(name string, rg *git.Repository) (*Repo, error) {
266 r := &Repo{
267 Name: name,
268 Repository: rg,
269 }
270 r.refCommits = make(map[plumbing.Hash]gitypes.Commits)
271 ref, err := rg.Head()
272 if err != nil {
273 return nil, err
274 }
275 r.ref = ref
276 rm, err := r.LatestFile("README.md")
277 if err != nil {
278 return nil, err
279 }
280 r.Readme = rm
281 refs := make([]*plumbing.Reference, 0)
282 ri, err := rg.References()
283 if err != nil {
284 return nil, err
285 }
286 ri.ForEach(func(r *plumbing.Reference) error {
287 refs = append(refs, r)
288 return nil
289 })
290 r.refs = refs
291 return r, nil
292}
293
294// LatestFile returns the latest file at the specified path in the repository.
295func (r *Repo) LatestFile(path string) (string, error) {
296 lg, err := r.Repository.Log(&git.LogOptions{
297 From: r.GetHEAD().Hash(),
298 })
299 if err != nil {
300 return "", err
301 }
302 c, err := lg.Next()
303 if err != nil {
304 return "", err
305 }
306 f, err := c.File(path)
307 if err != nil {
308 return "", err
309 }
310 content, err := f.Contents()
311 if err != nil {
312 return "", err
313 }
314 return content, nil
315}