git.go

  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