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