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