git.go

  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}