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	"io"
 11	"net/url"
 12	"os"
 13	"sort"
 14	"strings"
 15	"time"
 16
 17	"github.com/microcosm-cc/bluemonday"
 18
 19	"github.com/go-git/go-git/v5"
 20	"github.com/go-git/go-git/v5/plumbing"
 21	"github.com/go-git/go-git/v5/plumbing/transport"
 22)
 23
 24type Release struct {
 25	Tag     string
 26	Content string
 27	URL     string
 28	Date    time.Time
 29}
 30
 31var (
 32	bmUGC    = bluemonday.UGCPolicy()
 33	bmStrict = bluemonday.StrictPolicy()
 34)
 35
 36// listRemoteTags lists all tags in a remote repository, whether HTTP(S) or SSH.
 37// func listRemoteTags(url string) (tags []string, err error) {
 38// 	// TODO: Implement listRemoteTags
 39// 	// https://pkg.go.dev/github.com/go-git/go-git/v5@v5.8.0#NewRemote
 40// 	return nil, nil
 41// }
 42
 43// GetReleases fetches all releases in a remote repository, whether HTTP(S) or
 44// SSH.
 45func GetReleases(gitURI, forge string) ([]Release, error) {
 46	r, err := minimalClone(gitURI)
 47	if err != nil {
 48		return nil, err
 49	}
 50	tagRefs, err := r.Tags()
 51	if err != nil {
 52		return nil, err
 53	}
 54
 55	parsedURI, err := url.Parse(gitURI)
 56	if err != nil {
 57		fmt.Println("Error parsing URI: " + err.Error())
 58	}
 59
 60	var httpURI string
 61	if parsedURI.Scheme != "" {
 62		httpURI = parsedURI.Host + parsedURI.Path
 63	}
 64
 65	releases := make([]Release, 0)
 66
 67	err = tagRefs.ForEach(func(tagRef *plumbing.Reference) error {
 68		tagObj, err := r.TagObject(tagRef.Hash())
 69
 70		var message string
 71		var date time.Time
 72		if errors.Is(err, plumbing.ErrObjectNotFound) {
 73			commitTag, err := r.CommitObject(tagRef.Hash())
 74			if err != nil {
 75				return err
 76			}
 77			message = commitTag.Message
 78			date = commitTag.Committer.When
 79		} else {
 80			message = tagObj.Message
 81			date = tagObj.Tagger.When
 82		}
 83
 84		tagURL := ""
 85		tagName := bmStrict.Sanitize(tagRef.Name().Short())
 86		switch forge {
 87		case "sourcehut":
 88			tagURL = "https://" + httpURI + "/refs/" + tagName
 89		case "gitlab":
 90			tagURL = "https://" + httpURI + "/-/releases/" + tagName
 91		default:
 92			tagURL = ""
 93		}
 94
 95		releases = append(releases, Release{
 96			Tag:     tagName,
 97			Content: bmUGC.Sanitize(message),
 98			URL:     tagURL,
 99			Date:    date,
100		})
101		return nil
102	})
103	if err != nil {
104		return nil, err
105	}
106
107	sort.Slice(releases, func(i, j int) bool { return releases[i].Date.After(releases[j].Date) })
108
109	return releases, nil
110}
111
112// minimalClone clones a repository with a depth of 1 and no checkout.
113func minimalClone(url string) (r *git.Repository, err error) {
114	path, err := stringifyRepo(url)
115	if err != nil {
116		return nil, err
117	}
118
119	if _, err := os.Stat(path); err == nil {
120		r, err := git.PlainOpen(path)
121		if err != nil {
122			return nil, err
123		}
124		err = r.Fetch(&git.FetchOptions{
125			RemoteName: "origin",
126			Depth:      1,
127			Tags:       git.AllTags,
128		})
129		if errors.Is(err, git.NoErrAlreadyUpToDate) {
130			return r, nil
131		}
132		return r, err
133	}
134
135	r, err = git.PlainClone(path, false, &git.CloneOptions{
136		URL:          url,
137		SingleBranch: true,
138		NoCheckout:   true,
139		Depth:        1,
140	})
141	return r, err
142}
143
144// RemoveRepo removes a repository from the local filesystem.
145func RemoveRepo(url string) (err error) {
146	path, err := stringifyRepo(url)
147	if err != nil {
148		return err
149	}
150	err = os.RemoveAll(path)
151	if err != nil {
152		return err
153	}
154
155	// TODO: Check whether the two parent directories are empty and remove them if
156	// so
157	for i := 0; i < 2; i++ {
158		path = strings.TrimSuffix(path, "/")
159		if path == "data" {
160			break
161		}
162		empty, err := dirEmpty(path)
163		if err != nil {
164			return err
165		}
166		if empty {
167			err = os.Remove(path)
168			if err != nil {
169				return err
170			}
171		}
172		path = path[:strings.LastIndex(path, "/")]
173	}
174
175	return err
176}
177
178// dirEmpty checks if a directory is empty.
179func dirEmpty(name string) (empty bool, err error) {
180	f, err := os.Open(name)
181	if err != nil {
182		return false, err
183	}
184	defer f.Close()
185
186	_, err = f.Readdirnames(1)
187	if err == io.EOF {
188		return true, nil
189	}
190	return false, err
191}
192
193// stringifyRepo accepts a repository URI string and the corresponding local
194// filesystem path, whether the URI is HTTP, HTTPS, or SSH.
195func stringifyRepo(url string) (path string, err error) {
196	ep, err := transport.NewEndpoint(url)
197	if err != nil {
198		return "", err
199	}
200
201	if ep.Protocol == "http" || ep.Protocol == "https" {
202		return "data/" + strings.Split(url, "://")[1], nil
203	} else if ep.Protocol == "ssh" {
204		return "data/" + ep.Host + ep.Path, nil
205	} else {
206		return "", errors.New("unsupported protocol")
207	}
208}