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}
 32
 33// GetName returns the name of the repository.
 34func (r *Repo) Name() string {
 35	return r.name
 36}
 37
 38// GetHEAD returns the reference for a repository.
 39func (r *Repo) GetHEAD() *plumbing.Reference {
 40	return r.head
 41}
 42
 43// SetHEAD sets the repository head reference.
 44func (r *Repo) SetHEAD(ref *plumbing.Reference) error {
 45	r.head = ref
 46	return nil
 47}
 48
 49func (r *Repo) GetReferences() []*plumbing.Reference {
 50	return r.refs
 51}
 52
 53// GetRepository returns the underlying go-git repository object.
 54func (r *Repo) Repository() *git.Repository {
 55	return r.repository
 56}
 57
 58// Tree returns the git tree for a given path.
 59func (r *Repo) Tree(ref *plumbing.Reference, path string) (*object.Tree, error) {
 60	path = filepath.Clean(path)
 61	hash, err := r.targetHash(ref)
 62	if err != nil {
 63		return nil, err
 64	}
 65	c, err := r.repository.CommitObject(hash)
 66	if err != nil {
 67		return nil, err
 68	}
 69	t, err := c.Tree()
 70	if err != nil {
 71		return nil, err
 72	}
 73	if path == "." {
 74		return t, nil
 75	}
 76	return t.Tree(path)
 77}
 78
 79// GetCommits returns the commits for a repository.
 80func (r *Repo) GetCommits(ref *plumbing.Reference) (gitypes.Commits, error) {
 81	hash, err := r.targetHash(ref)
 82	if err != nil {
 83		return nil, err
 84	}
 85	// return cached commits if available
 86	commits, ok := r.refCommits[hash]
 87	if ok {
 88		return commits, nil
 89	}
 90	log.Printf("caching commits for %s/%s: %s", r.name, ref.Name(), ref.Hash())
 91	commits = gitypes.Commits{}
 92	co, err := r.repository.CommitObject(hash)
 93	if err != nil {
 94		return nil, err
 95	}
 96	// traverse the commit tree to get all commits
 97	commits = append(commits, &gitypes.Commit{Commit: co})
 98	for {
 99		co, err = co.Parent(0)
100		if err != nil {
101			if err == object.ErrParentNotFound {
102				err = nil
103			}
104			break
105		}
106		commits = append(commits, &gitypes.Commit{Commit: co})
107	}
108	if err != nil {
109		return nil, err
110	}
111	sort.Sort(commits)
112	// cache the commits in the repo
113	r.refCommits[hash] = commits
114	return commits, nil
115}
116
117// targetHash returns the target hash for a given reference. If reference is an
118// annotated tag, find the target hash for that tag.
119func (r *Repo) targetHash(ref *plumbing.Reference) (plumbing.Hash, error) {
120	hash := ref.Hash()
121	if ref.Type() != plumbing.HashReference {
122		return plumbing.ZeroHash, plumbing.ErrInvalidType
123	}
124	if ref.Name().IsTag() {
125		to, err := r.repository.TagObject(hash)
126		switch err {
127		case nil:
128			// annotated tag (object has a target hash)
129			hash = to.Target
130		case plumbing.ErrObjectNotFound:
131			// lightweight tag (hash points to a commit)
132		default:
133			return plumbing.ZeroHash, err
134		}
135	}
136	return hash, nil
137}
138
139// loadCommits loads the commits for a repository.
140func (r *Repo) loadCommits(ref *plumbing.Reference) (gitypes.Commits, error) {
141	commits := gitypes.Commits{}
142	hash, err := r.targetHash(ref)
143	if err != nil {
144		return nil, err
145	}
146	l, err := r.repository.Log(&git.LogOptions{
147		Order: git.LogOrderCommitterTime,
148		From:  hash,
149	})
150	if err != nil {
151		return nil, err
152	}
153	defer l.Close()
154	err = l.ForEach(func(c *object.Commit) error {
155		commits = append(commits, &gitypes.Commit{Commit: c})
156		return nil
157	})
158	if err != nil {
159		return nil, err
160	}
161	return commits, 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.refCommits = make(map[plumbing.Hash]gitypes.Commits)
269	ref, err := rg.Head()
270	if err != nil {
271		return nil, err
272	}
273	r.head = ref
274	rm, err := r.LatestFile("README.md")
275	if err != nil {
276		return nil, err
277	}
278	r.Readme = rm
279	refs := make([]*plumbing.Reference, 0)
280	ri, err := rg.References()
281	if err != nil {
282		return nil, err
283	}
284	ri.ForEach(func(r *plumbing.Reference) error {
285		refs = append(refs, r)
286		return nil
287	})
288	r.refs = refs
289	return r, nil
290}
291
292// LatestFile returns the latest file at the specified path in the repository.
293func (r *Repo) LatestFile(path string) (string, error) {
294	lg, err := r.repository.Log(&git.LogOptions{
295		From: r.GetHEAD().Hash(),
296	})
297	if err != nil {
298		return "", err
299	}
300	c, err := lg.Next()
301	if err != nil {
302		return "", err
303	}
304	f, err := c.File(path)
305	if err != nil {
306		return "", err
307	}
308	content, err := f.Contents()
309	if err != nil {
310		return "", err
311	}
312	return content, nil
313}