/ git / git.go
git.go
  1  // SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
  2  //
  3  // SPDX-License-Identifier: Apache-2.0
  4  
  5  package git
  6  
  7  import (
  8  	"errors"
  9  	"fmt"
 10  	"net/url"
 11  	"os"
 12  	"strings"
 13  	"time"
 14  
 15  	"github.com/microcosm-cc/bluemonday"
 16  
 17  	"github.com/go-git/go-git/v5"
 18  	"github.com/go-git/go-git/v5/plumbing"
 19  	"github.com/go-git/go-git/v5/plumbing/transport"
 20  )
 21  
 22  type Release struct {
 23  	Tag     string
 24  	Content string
 25  	URL     string
 26  	Date    time.Time
 27  }
 28  
 29  var (
 30  	bmUGC    = bluemonday.UGCPolicy()
 31  	bmStrict = bluemonday.StrictPolicy()
 32  )
 33  
 34  // listRemoteTags lists all tags in a remote repository, whether HTTP(S) or SSH.
 35  // func listRemoteTags(url string) (tags []string, err error) {
 36  // 	// TODO: Implement listRemoteTags
 37  // 	// https://pkg.go.dev/github.com/go-git/go-git/v5@v5.8.0#NewRemote
 38  // 	return nil, nil
 39  // }
 40  
 41  // GetReleases fetches all releases in a remote repository, whether HTTP(S) or
 42  // SSH.
 43  func GetReleases(gitURI, forge string) ([]Release, error) {
 44  	r, err := minimalClone(gitURI)
 45  	if err != nil {
 46  		return nil, err
 47  	}
 48  	tagRefs, err := r.Tags()
 49  	if err != nil {
 50  		return nil, err
 51  	}
 52  
 53  	parsedURI, err := url.Parse(gitURI)
 54  	if err != nil {
 55  		fmt.Println("Error parsing URI: " + err.Error())
 56  	}
 57  
 58  	var httpURI string
 59  	if parsedURI.Scheme != "" {
 60  		httpURI = parsedURI.Host + parsedURI.Path
 61  	}
 62  
 63  	releases := make([]Release, 0)
 64  
 65  	err = tagRefs.ForEach(func(tagRef *plumbing.Reference) error {
 66  		tagObj, err := r.TagObject(tagRef.Hash())
 67  
 68  		var message string
 69  		var date time.Time
 70  		if errors.Is(err, plumbing.ErrObjectNotFound) {
 71  			commitTag, err := r.CommitObject(tagRef.Hash())
 72  			if err != nil {
 73  				return err
 74  			}
 75  			message = commitTag.Message
 76  			date = commitTag.Committer.When
 77  		} else {
 78  			message = tagObj.Message
 79  			date = tagObj.Tagger.When
 80  		}
 81  
 82  		tagURL := ""
 83  		tagName := bmStrict.Sanitize(tagRef.Name().Short())
 84  		switch forge {
 85  		case "sourcehut":
 86  			tagURL = "https://" + httpURI + "/refs/" + tagName
 87  		case "gitlab":
 88  			tagURL = "https://" + httpURI + "/-/releases/" + tagName
 89  		default:
 90  			tagURL = ""
 91  		}
 92  
 93  		releases = append(releases, Release{
 94  			Tag:     tagName,
 95  			Content: bmUGC.Sanitize(message),
 96  			URL:     tagURL,
 97  			Date:    date,
 98  		})
 99  		return nil
100  	})
101  	if err != nil {
102  		return nil, err
103  	}
104  
105  	return releases, nil
106  }
107  
108  // minimalClone clones a repository with a depth of 1 and no checkout.
109  func minimalClone(url string) (r *git.Repository, err error) {
110  	path, err := stringifyRepo(url)
111  	if err != nil {
112  		return nil, err
113  	}
114  
115  	r, err = git.PlainOpen(path)
116  	if err == nil {
117  		err = r.Fetch(&git.FetchOptions{
118  			RemoteName: "origin",
119  			Depth:      1,
120  			Tags:       git.AllTags,
121  		})
122  		if errors.Is(err, git.NoErrAlreadyUpToDate) {
123  			return r, nil
124  		}
125  		return r, err
126  	} else if !errors.Is(err, git.ErrRepositoryNotExists) {
127  		return nil, err
128  	}
129  
130  	r, err = git.PlainClone(path, false, &git.CloneOptions{
131  		URL:          url,
132  		SingleBranch: true,
133  		NoCheckout:   true,
134  		Depth:        1,
135  	})
136  	return r, err
137  }
138  
139  // RemoveRepo removes a repository from the local filesystem.
140  func RemoveRepo(url string) (err error) {
141  	path, err := stringifyRepo(url)
142  	if err != nil {
143  		return err
144  	}
145  	err = os.RemoveAll(path)
146  	if err != nil {
147  		return err
148  	}
149  
150  	path = path[:strings.LastIndex(path, "/")]
151  	dirs := strings.Split(path, "/")
152  
153  	for range dirs {
154  		if path == "data" {
155  			break
156  		}
157  		err = os.Remove(path)
158  		if err != nil {
159  			// This folder likely has data, so might as well save some time by
160  			// not checking the parents we can't delete anyway.
161  			break
162  		}
163  		path = path[:strings.LastIndex(path, "/")]
164  	}
165  
166  	return nil
167  }
168  
169  // stringifyRepo accepts a repository URI string and the corresponding local
170  // filesystem path, whether the URI is HTTP, HTTPS, or SSH.
171  func stringifyRepo(url string) (path string, err error) {
172  	url = strings.TrimSuffix(url, ".git")
173  	url = strings.TrimSuffix(url, "/")
174  
175  	ep, err := transport.NewEndpoint(url)
176  	if err != nil {
177  		return "", err
178  	}
179  
180  	if ep.Protocol == "http" || ep.Protocol == "https" {
181  		return "data/" + strings.Split(url, "://")[1], nil
182  	} else if ep.Protocol == "ssh" {
183  		return "data/" + ep.Host + "/" + ep.Path, nil
184  	} else {
185  		return "", errors.New("unsupported protocol")
186  	}
187  }