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