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}