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