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		tagName := bmStrict.Sanitize(tagRef.Name().Short())
 88
 89		switch forge {
 90		case "sourcehut":
 91			tagURL = "https://" + httpURI + "/refs/" + tagName
 92		case "gitlab":
 93			tagURL = "https://" + httpURI + "/-/releases/" + tagName
 94		default:
 95			tagURL = ""
 96		}
 97
 98		releases = append(releases, Release{
 99			Tag:     tagName,
100			Content: bmUGC.Sanitize(message),
101			URL:     tagURL,
102			Date:    date,
103		})
104
105		return nil
106	})
107	if err != nil {
108		return nil, err
109	}
110
111	return releases, nil
112}
113
114// minimalClone clones a repository with a depth of 1 and no checkout.
115func minimalClone(url string) (r *git.Repository, err error) {
116	path, err := stringifyRepo(url)
117	if err != nil {
118		return nil, err
119	}
120
121	r, err = git.PlainOpen(path)
122	if err == nil {
123		err = r.Fetch(&git.FetchOptions{
124			RemoteName: "origin",
125			Depth:      1,
126			Tags:       git.AllTags,
127		})
128		if errors.Is(err, git.NoErrAlreadyUpToDate) {
129			return r, nil
130		}
131
132		return r, err
133	} else if !errors.Is(err, git.ErrRepositoryNotExists) {
134		return nil, err
135	}
136
137	r, err = git.PlainClone(path, false, &git.CloneOptions{
138		URL:          url,
139		SingleBranch: true,
140		NoCheckout:   true,
141		Depth:        1,
142	})
143
144	return r, err
145}
146
147// RemoveRepo removes a repository from the local filesystem.
148func RemoveRepo(url string) (err error) {
149	path, err := stringifyRepo(url)
150	if err != nil {
151		return err
152	}
153
154	err = os.RemoveAll(path)
155	if err != nil {
156		return err
157	}
158
159	path = path[:strings.LastIndex(path, "/")]
160	dirs := strings.Split(path, "/")
161
162	for range dirs {
163		if path == "data" {
164			break
165		}
166
167		err = os.Remove(path)
168		if err != nil {
169			// This folder likely has data, so might as well save some time by
170			// not checking the parents we can't delete anyway.
171			break
172		}
173
174		path = path[:strings.LastIndex(path, "/")]
175	}
176
177	return nil
178}
179
180// stringifyRepo accepts a repository URI string and the corresponding local
181// filesystem path, whether the URI is HTTP, HTTPS, or SSH.
182func stringifyRepo(url string) (path string, err error) {
183	url = strings.TrimSuffix(url, ".git")
184	url = strings.TrimSuffix(url, "/")
185
186	ep, err := transport.NewEndpoint(url)
187	if err != nil {
188		return "", err
189	}
190
191	switch ep.Protocol {
192	case "http", "https":
193		return "data/" + strings.Split(url, "://")[1], nil
194	case "ssh":
195		return "data/" + ep.Host + "/" + ep.Path, nil
196	default:
197		return "", errors.New("unsupported protocol")
198	}
199}