git.go

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