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}