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