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