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/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	path       string
 27	repository *git.Repository
 28	Readme     string
 29	refCommits map[plumbing.Hash]gitypes.Commits
 30	head       *plumbing.Reference
 31	refs       []*plumbing.Reference
 32	trees      map[plumbing.Hash]*object.Tree
 33	commits    map[plumbing.Hash]*object.Commit
 34	patch      map[plumbing.Hash]*object.Patch
 35}
 36
 37// GetName returns the name of the repository.
 38func (r *Repo) Name() string {
 39	return filepath.Base(r.path)
 40}
 41
 42// GetHEAD returns the reference for a repository.
 43func (r *Repo) GetHEAD() *plumbing.Reference {
 44	return r.head
 45}
 46
 47// SetHEAD sets the repository head reference.
 48func (r *Repo) SetHEAD(ref *plumbing.Reference) error {
 49	r.head = ref
 50	return nil
 51}
 52
 53// GetReferences returns the references for a repository.
 54func (r *Repo) GetReferences() []*plumbing.Reference {
 55	return r.refs
 56}
 57
 58// GetRepository returns the underlying go-git repository object.
 59func (r *Repo) Repository() *git.Repository {
 60	return r.repository
 61}
 62
 63// Tree returns the git tree for a given path.
 64func (r *Repo) Tree(ref *plumbing.Reference, path string) (*object.Tree, error) {
 65	path = filepath.Clean(path)
 66	hash, err := r.targetHash(ref)
 67	if err != nil {
 68		return nil, err
 69	}
 70	c, err := r.commitForHash(hash)
 71	if err != nil {
 72		return nil, err
 73	}
 74	t, err := r.treeForHash(c.TreeHash)
 75	if err != nil {
 76		return nil, err
 77	}
 78	if path == "." {
 79		return t, nil
 80	}
 81	return t.Tree(path)
 82}
 83
 84func (r *Repo) treeForHash(treeHash plumbing.Hash) (*object.Tree, error) {
 85	var err error
 86	t, ok := r.trees[treeHash]
 87	if !ok {
 88		t, err = r.repository.TreeObject(treeHash)
 89		if err != nil {
 90			return nil, err
 91		}
 92		r.trees[treeHash] = t
 93	}
 94	return t, nil
 95}
 96
 97func (r *Repo) commitForHash(hash plumbing.Hash) (*object.Commit, error) {
 98	var err error
 99	co, ok := r.commits[hash]
100	if !ok {
101		co, err = r.repository.CommitObject(hash)
102		if err != nil {
103			return nil, err
104		}
105		r.commits[hash] = co
106	}
107	return co, nil
108}
109
110func (r *Repo) PatchCtx(ctx context.Context, commit *object.Commit) (*object.Patch, error) {
111	hash := commit.Hash
112	p, ok := r.patch[hash]
113	if !ok {
114		c, err := r.commitForHash(hash)
115		if err != nil {
116			return nil, err
117		}
118		// Using commit trees fixes the issue when generating diff for the first commit
119		// https://github.com/go-git/go-git/issues/281
120		tree, err := r.treeForHash(c.TreeHash)
121		if err != nil {
122			return nil, err
123		}
124		var parent *object.Commit
125		parentTree := &object.Tree{}
126		if c.NumParents() > 0 {
127			parent, err = r.commitForHash(c.ParentHashes[0])
128			if err != nil {
129				return nil, err
130			}
131			parentTree, err = r.treeForHash(parent.TreeHash)
132			if err != nil {
133				return nil, err
134			}
135		}
136		p, err = parentTree.PatchContext(ctx, tree)
137		if err != nil {
138			return nil, err
139		}
140	}
141	return p, nil
142}
143
144// GetCommits returns the commits for a repository.
145func (r *Repo) GetCommits(ref *plumbing.Reference) (gitypes.Commits, error) {
146	hash, err := r.targetHash(ref)
147	if err != nil {
148		return nil, err
149	}
150	// return cached commits if available
151	commits, ok := r.refCommits[hash]
152	if ok {
153		return commits, nil
154	}
155	commits = gitypes.Commits{}
156	co, err := r.commitForHash(hash)
157	if err != nil {
158		return nil, err
159	}
160	// traverse the commit tree to get all commits
161	commits = append(commits, co)
162	for co.NumParents() > 0 {
163		co, err = r.commitForHash(co.ParentHashes[0])
164		if err != nil {
165			return nil, err
166		}
167		commits = append(commits, co)
168	}
169	if err != nil {
170		return nil, err
171	}
172	sort.Sort(commits)
173	// cache the commits in the repo
174	r.refCommits[hash] = commits
175	return commits, nil
176}
177
178// targetHash returns the target hash for a given reference. If reference is an
179// annotated tag, find the target hash for that tag.
180func (r *Repo) targetHash(ref *plumbing.Reference) (plumbing.Hash, error) {
181	hash := ref.Hash()
182	if ref.Type() != plumbing.HashReference {
183		return plumbing.ZeroHash, plumbing.ErrInvalidType
184	}
185	if ref.Name().IsTag() {
186		to, err := r.repository.TagObject(hash)
187		switch err {
188		case nil:
189			// annotated tag (object has a target hash)
190			hash = to.Target
191		case plumbing.ErrObjectNotFound:
192			// lightweight tag (hash points to a commit)
193		default:
194			return plumbing.ZeroHash, err
195		}
196	}
197	return hash, nil
198}
199
200// GetReadme returns the readme for a repository.
201func (r *Repo) GetReadme() string {
202	if r.Readme != "" {
203		return r.Readme
204	}
205	md, err := r.LatestFile("README.md")
206	if err != nil {
207		return ""
208	}
209	return md
210}
211
212// RepoSource is a reference to an on-disk repositories.
213type RepoSource struct {
214	Path  string
215	mtx   sync.Mutex
216	repos []*Repo
217}
218
219// NewRepoSource creates a new RepoSource.
220func NewRepoSource(repoPath string) *RepoSource {
221	err := os.MkdirAll(repoPath, os.ModeDir|os.FileMode(0700))
222	if err != nil {
223		log.Fatal(err)
224	}
225	rs := &RepoSource{Path: repoPath}
226	return rs
227}
228
229// AllRepos returns all repositories for the given RepoSource.
230func (rs *RepoSource) AllRepos() []*Repo {
231	rs.mtx.Lock()
232	defer rs.mtx.Unlock()
233	return rs.repos
234}
235
236// GetRepo returns a repository by name.
237func (rs *RepoSource) GetRepo(name string) (*Repo, error) {
238	rs.mtx.Lock()
239	defer rs.mtx.Unlock()
240	for _, r := range rs.repos {
241		if filepath.Base(r.path) == name {
242			return r, nil
243		}
244	}
245	return nil, ErrMissingRepo
246}
247
248// InitRepo initializes a new Git repository.
249func (rs *RepoSource) InitRepo(name string, bare bool) (*Repo, error) {
250	rs.mtx.Lock()
251	defer rs.mtx.Unlock()
252	rp := filepath.Join(rs.Path, name)
253	rg, err := git.PlainInit(rp, bare)
254	if err != nil {
255		return nil, err
256	}
257	if bare {
258		// Clone repo into memory storage
259		ar, err := git.Clone(memory.NewStorage(), memfs.New(), &git.CloneOptions{
260			URL: rp,
261		})
262		if err != nil && err != transport.ErrEmptyRemoteRepository {
263			return nil, err
264		}
265		rg = ar
266	}
267	r := &Repo{
268		path:       rp,
269		repository: rg,
270	}
271	rs.repos = append(rs.repos, r)
272	return r, nil
273}
274
275// LoadRepos opens Git repositories.
276func (rs *RepoSource) LoadRepos() error {
277	rs.mtx.Lock()
278	defer rs.mtx.Unlock()
279	rd, err := os.ReadDir(rs.Path)
280	if err != nil {
281		return err
282	}
283	rs.repos = make([]*Repo, 0)
284	for _, de := range rd {
285		rp := filepath.Join(rs.Path, de.Name())
286		rg, err := git.PlainOpen(rp)
287		if err != nil {
288			return err
289		}
290		r, err := rs.loadRepo(rp, rg)
291		if err != nil {
292			return err
293		}
294		rs.repos = append(rs.repos, r)
295	}
296	return nil
297}
298
299func (rs *RepoSource) loadRepo(path string, rg *git.Repository) (*Repo, error) {
300	r := &Repo{
301		path:       path,
302		repository: rg,
303		patch:      make(map[plumbing.Hash]*object.Patch),
304	}
305	r.commits = make(map[plumbing.Hash]*object.Commit)
306	r.trees = make(map[plumbing.Hash]*object.Tree)
307	r.refCommits = make(map[plumbing.Hash]gitypes.Commits)
308	ref, err := rg.Head()
309	if err != nil {
310		return nil, err
311	}
312	r.head = ref
313	rm, err := r.LatestFile("README.md")
314	if err == object.ErrFileNotFound {
315		rm = ""
316	} else if err != nil {
317		return nil, err
318	}
319	r.Readme = rm
320	l, err := r.repository.Log(&git.LogOptions{All: true})
321	if err != nil {
322		return nil, err
323	}
324	err = l.ForEach(func(c *object.Commit) error {
325		r.commits[c.Hash] = c
326		return nil
327	})
328	if err != nil {
329		return nil, err
330	}
331	refs := make([]*plumbing.Reference, 0)
332	ri, err := rg.References()
333	if err != nil {
334		return nil, err
335	}
336	ri.ForEach(func(r *plumbing.Reference) error {
337		refs = append(refs, r)
338		return nil
339	})
340	r.refs = refs
341	return r, nil
342}
343
344// LatestFile returns the latest file at the specified path in the repository.
345func (r *Repo) LatestFile(path string) (string, error) {
346	c, err := r.commitForHash(r.head.Hash())
347	if err != nil {
348		return "", err
349	}
350	f, err := c.File(path)
351	if err != nil {
352		return "", err
353	}
354	content, err := f.Contents()
355	if err != nil {
356		return "", err
357	}
358	return content, nil
359}
360
361// LatestTree returns the latest tree at the specified path in the repository.
362func (r *Repo) LatestTree(path string) (*object.Tree, error) {
363	return r.Tree(r.head, path)
364}
365