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