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/go-git/go-git/v5"
 16	"github.com/go-git/go-git/v5/plumbing"
 17	"github.com/go-git/go-git/v5/plumbing/transport"
 18	"github.com/microcosm-cc/bluemonday"
 19)
 20
 21type Release struct {
 22	Tag     string
 23	Content string
 24	URL     string
 25	Date    time.Time
 26}
 27
 28var (
 29	bmUGC    = bluemonday.UGCPolicy()
 30	bmStrict = bluemonday.StrictPolicy()
 31)
 32
 33// listRemoteTags lists all tags in a remote repository, whether HTTP(S) or SSH.
 34// func listRemoteTags(url string) (tags []string, err error) {
 35// 	// TODO: Implement listRemoteTags
 36// 	// https://pkg.go.dev/github.com/go-git/go-git/v5@v5.8.0#NewRemote
 37// 	return nil, nil
 38// }
 39
 40// GetReleases fetches all releases in a remote repository, whether HTTP(S) or
 41// SSH.
 42func GetReleases(gitURI, forge string) ([]Release, error) {
 43	r, err := minimalClone(gitURI)
 44	if err != nil {
 45		return nil, err
 46	}
 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 (
 69			message string
 70			date    time.Time
 71		)
 72
 73		if errors.Is(err, plumbing.ErrObjectNotFound) {
 74			commitTag, err := r.CommitObject(tagRef.Hash())
 75			if err != nil {
 76				return err
 77			}
 78
 79			message = commitTag.Message
 80			date = commitTag.Committer.When
 81		} else {
 82			message = tagObj.Message
 83			date = tagObj.Tagger.When
 84		}
 85
 86		var tagURL string
 87
 88		tagName := bmStrict.Sanitize(tagRef.Name().Short())
 89
 90		switch forge {
 91		case "sourcehut":
 92			tagURL = "https://" + httpURI + "/refs/" + tagName
 93		case "gitlab":
 94			tagURL = "https://" + httpURI + "/-/releases/" + tagName
 95		default:
 96			tagURL = ""
 97		}
 98
 99		releases = append(releases, Release{
100			Tag:     tagName,
101			Content: bmUGC.Sanitize(message),
102			URL:     tagURL,
103			Date:    date,
104		})
105
106		return nil
107	})
108	if err != nil {
109		return nil, err
110	}
111
112	return releases, nil
113}
114
115// minimalClone clones a repository with a depth of 1 and no checkout.
116func minimalClone(url string) (r *git.Repository, err error) {
117	path, err := stringifyRepo(url)
118	if err != nil {
119		return nil, err
120	}
121
122	r, err = git.PlainOpen(path)
123	if err == nil {
124		err = r.Fetch(&git.FetchOptions{
125			RemoteName:      "origin",
126			RemoteURL:       "",
127			RefSpecs:        nil,
128			Depth:           1,
129			Auth:            nil,
130			Progress:        nil,
131			Tags:            git.AllTags,
132			Force:           false,
133			InsecureSkipTLS: false,
134			CABundle:        nil,
135			ProxyOptions: transport.ProxyOptions{
136				URL:      "",
137				Username: "",
138				Password: "",
139			},
140		})
141		if errors.Is(err, git.NoErrAlreadyUpToDate) {
142			return r, nil
143		}
144
145		return r, err
146	} else if !errors.Is(err, git.ErrRepositoryNotExists) {
147		return nil, err
148	}
149
150	r, err = git.PlainClone(path, false, &git.CloneOptions{
151		URL:               url,
152		Auth:              nil,
153		RemoteName:        "",
154		ReferenceName:     "",
155		SingleBranch:      true,
156		NoCheckout:        true,
157		Depth:             1,
158		RecurseSubmodules: 0,
159		Progress:          nil,
160		Tags:              0,
161		InsecureSkipTLS:   false,
162		CABundle:          nil,
163		ProxyOptions: transport.ProxyOptions{
164			URL:      "",
165			Username: "",
166			Password: "",
167		},
168		Mirror:            false,
169		ShallowSubmodules: false,
170		Shared:            false,
171	})
172
173	return r, err
174}
175
176// RemoveRepo removes a repository from the local filesystem.
177func RemoveRepo(url string) (err error) {
178	path, err := stringifyRepo(url)
179	if err != nil {
180		return err
181	}
182
183	err = os.RemoveAll(path)
184	if err != nil {
185		return err
186	}
187
188	path = path[:strings.LastIndex(path, "/")]
189	dirs := strings.Split(path, "/")
190
191	for range dirs {
192		if path == "data" {
193			break
194		}
195
196		err = os.Remove(path)
197		if err != nil {
198			// This folder likely has data, so might as well save some time by
199			// not checking the parents we can't delete anyway.
200			break
201		}
202
203		path = path[:strings.LastIndex(path, "/")]
204	}
205
206	return nil
207}
208
209// stringifyRepo accepts a repository URI string and the corresponding local
210// filesystem path, whether the URI is HTTP, HTTPS, or SSH.
211func stringifyRepo(url string) (path string, err error) {
212	url = strings.TrimSuffix(url, ".git")
213	url = strings.TrimSuffix(url, "/")
214
215	ep, err := transport.NewEndpoint(url)
216	if err != nil {
217		return "", err
218	}
219
220	switch ep.Protocol {
221	case "http", "https":
222		return "data/" + strings.Split(url, "://")[1], nil
223	case "ssh":
224		return "data/" + ep.Host + "/" + ep.Path, nil
225	default:
226		return "", errors.New("unsupported protocol")
227	}
228}