git.go

  1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
  2//
  3// SPDX-License-Identifier: Apache-2.0
  4
  5package git
  6
  7import (
  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
 22type Release struct {
 23	Tag     string
 24	Content string
 25	URL     string
 26	Date    time.Time
 27}
 28
 29var (
 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.
 43func 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.
109func minimalClone(url string) (*git.Repository, 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.
140func RemoveRepo(url string) 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.
171func stringifyRepo(url string) (string, 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	}
185	return "", errors.New("unsupported protocol")
186}