Monorepo for Tangled
tangled.org
1package knotmirror
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "net/url"
8 "os"
9 "os/exec"
10 "path/filepath"
11 "regexp"
12 "strings"
13
14 "github.com/go-git/go-git/v5"
15 gitconfig "github.com/go-git/go-git/v5/config"
16 "github.com/go-git/go-git/v5/plumbing/transport"
17 "tangled.org/core/knotmirror/models"
18)
19
20type GitMirrorClient interface {
21 MakeRepoPath(repo *models.Repo) string
22 MakeRepoRemoteUrl(repo *models.Repo) (string, error)
23 // RemoteSetUrl updates git repository 'origin' remote
24 RemoteSetUrl(ctx context.Context, repo *models.Repo) error
25 // Clone clones the repository as a mirror
26 Clone(ctx context.Context, repo *models.Repo) error
27 // Fetch fetches the repository
28 Fetch(ctx context.Context, repo *models.Repo) error
29 // Sync mirrors the repository. It will clone the repository if repository doesn't exist.
30 Sync(ctx context.Context, repo *models.Repo) error
31}
32
33type CliGitMirrorClient struct {
34 repoBasePath string
35 knotUseSSL bool
36}
37
38func NewCliGitMirrorClient(repoBasePath string, knotUseSSL bool) *CliGitMirrorClient {
39 return &CliGitMirrorClient{
40 repoBasePath,
41 knotUseSSL,
42 }
43}
44
45var _ GitMirrorClient = new(CliGitMirrorClient)
46
47func (c *CliGitMirrorClient) MakeRepoPath(repo *models.Repo) string {
48 return filepath.Join(c.repoBasePath, repo.Did.String(), repo.Rkey.String())
49}
50
51func (c *CliGitMirrorClient) MakeRepoRemoteUrl(repo *models.Repo) (string, error) {
52 u, err := url.Parse(repo.KnotDomain)
53 if err != nil {
54 return "", err
55 }
56 if u.Scheme == "" {
57 if c.knotUseSSL {
58 u.Scheme = "https"
59 } else {
60 u.Scheme = "http"
61 }
62 }
63 u = u.JoinPath(repo.DidSlashRepo())
64 return u.String(), nil
65}
66
67func (c *CliGitMirrorClient) RemoteSetUrl(ctx context.Context, repo *models.Repo) error {
68 path := c.MakeRepoPath(repo)
69 url, err := c.MakeRepoRemoteUrl(repo)
70 if err != nil {
71 return fmt.Errorf("constructing repo remote url: %w", err)
72 }
73 cmd := exec.CommandContext(ctx, "git", "-C", path, "remote", "set-url", "origin", url)
74 if out, err := cmd.CombinedOutput(); err != nil {
75 if ctx.Err() != nil {
76 return ctx.Err()
77 }
78 msg := string(out)
79 return fmt.Errorf("running 'git remote set-url origin %s': %w\n%s", url, err, msg)
80 }
81 return nil
82}
83
84func (c *CliGitMirrorClient) Clone(ctx context.Context, repo *models.Repo) error {
85 path := c.MakeRepoPath(repo)
86 url, err := c.MakeRepoRemoteUrl(repo)
87 if err != nil {
88 return fmt.Errorf("constructing repo remote url: %w", err)
89 }
90 return c.clone(ctx, path, url)
91}
92
93func (c *CliGitMirrorClient) clone(ctx context.Context, path, url string) error {
94 cmd := exec.CommandContext(ctx, "git", "clone", "--mirror", url, path)
95 if out, err := cmd.CombinedOutput(); err != nil {
96 if ctx.Err() != nil {
97 return ctx.Err()
98 }
99 msg := string(out)
100 if classification := classifyCliError(msg); classification != nil {
101 return classification
102 }
103 return fmt.Errorf("running 'git clone --mirror %s': %w\n%s", url, err, msg)
104 }
105 return nil
106}
107
108func (c *CliGitMirrorClient) Fetch(ctx context.Context, repo *models.Repo) error {
109 path := c.MakeRepoPath(repo)
110 return c.fetch(ctx, path)
111}
112
113func (c *CliGitMirrorClient) fetch(ctx context.Context, path string) error {
114 // TODO: use `repo.Knot` instead of depending on origin
115 cmd := exec.CommandContext(ctx, "git", "-C", path, "fetch", "--prune", "origin")
116 if out, err := cmd.CombinedOutput(); err != nil {
117 if ctx.Err() != nil {
118 return ctx.Err()
119 }
120 return fmt.Errorf("running 'git fetch': %w\n%s", err, string(out))
121 }
122 return nil
123}
124
125func (c *CliGitMirrorClient) Sync(ctx context.Context, repo *models.Repo) error {
126 path := c.MakeRepoPath(repo)
127 url, err := c.MakeRepoRemoteUrl(repo)
128 if err != nil {
129 return fmt.Errorf("constructing repo remote url: %w", err)
130 }
131
132 exist, err := isDir(path)
133 if err != nil {
134 return fmt.Errorf("checking repo path: %w", err)
135 }
136 if !exist {
137 if err := c.clone(ctx, path, url); err != nil {
138 return fmt.Errorf("cloning repo: %w", err)
139 }
140 } else {
141 if err := c.fetch(ctx, path); err != nil {
142 return fmt.Errorf("fetching repo: %w", err)
143 }
144 }
145 return nil
146}
147
148var (
149 ErrDNSFailure = errors.New("git: knot: dns failure (could not resolve host)")
150 ErrCertExpired = errors.New("git: knot: certificate has expired")
151 ErrCertMismatch = errors.New("git: knot: certificate hostname mismatch")
152 ErrTLSHandshake = errors.New("git: knot: tls handshake failure")
153 ErrHTTPStatus = errors.New("git: knot: request url returned error")
154 ErrUnreachable = errors.New("git: knot: could not connect to server")
155 ErrRepoNotFound = errors.New("git: repo: repository not found")
156)
157
158var (
159 reDNSFailure = regexp.MustCompile(`Could not resolve host:`)
160 reCertExpired = regexp.MustCompile(`SSL certificate OpenSSL verify result: certificate has expired`)
161 reCertMismatch = regexp.MustCompile(`SSL: no alternative certificate subject name matches target hostname`)
162 reTLSHandshake = regexp.MustCompile(`TLS connect error: (.*)`)
163 reHTTPStatus = regexp.MustCompile(`The requested URL returned error: (\d\d\d)`)
164 reUnreachable = regexp.MustCompile(`Could not connect to server`)
165 reRepoNotFound = regexp.MustCompile(`repository '.*?' not found`)
166)
167
168// classifyCliError classifies git cli error message. It will return nil for unknown error messages
169func classifyCliError(stderr string) error {
170 msg := strings.TrimSpace(stderr)
171 if m := reTLSHandshake.FindStringSubmatch(msg); len(m) > 1 {
172 return fmt.Errorf("%w: %s", ErrTLSHandshake, m[1])
173 }
174 if m := reHTTPStatus.FindStringSubmatch(msg); len(m) > 1 {
175 return fmt.Errorf("%w: %s", ErrHTTPStatus, m[1])
176 }
177 switch {
178 case reDNSFailure.MatchString(msg):
179 return ErrDNSFailure
180 case reCertExpired.MatchString(msg):
181 return ErrCertExpired
182 case reCertMismatch.MatchString(msg):
183 return ErrCertMismatch
184 case reUnreachable.MatchString(msg):
185 return ErrUnreachable
186 case reRepoNotFound.MatchString(msg):
187 return ErrRepoNotFound
188 }
189 return nil
190}
191
192type GoGitMirrorClient struct {
193 repoBasePath string
194 knotUseSSL bool
195}
196
197func NewGoGitMirrorClient(repoBasePath string, knotUseSSL bool) *GoGitMirrorClient {
198 return &GoGitMirrorClient{
199 repoBasePath,
200 knotUseSSL,
201 }
202}
203
204var _ GitMirrorClient = new(GoGitMirrorClient)
205
206func (c *GoGitMirrorClient) MakeRepoPath(repo *models.Repo) string {
207 return filepath.Join(c.repoBasePath, repo.Did.String(), repo.Rkey.String())
208}
209
210func (c *GoGitMirrorClient) MakeRepoRemoteUrl(repo *models.Repo) (string, error) {
211 u, err := url.Parse(repo.KnotDomain)
212 if err != nil {
213 return "", err
214 }
215 if u.Scheme == "" {
216 if c.knotUseSSL {
217 u.Scheme = "https"
218 } else {
219 u.Scheme = "http"
220 }
221 }
222 u = u.JoinPath(repo.DidSlashRepo())
223 return u.String(), nil
224}
225
226func (c *GoGitMirrorClient) RemoteSetUrl(ctx context.Context, repo *models.Repo) error {
227 panic("unimplemented")
228}
229
230func (c *GoGitMirrorClient) Clone(ctx context.Context, repo *models.Repo) error {
231 path := c.MakeRepoPath(repo)
232 url, err := c.MakeRepoRemoteUrl(repo)
233 if err != nil {
234 return fmt.Errorf("constructing repo remote url: %w", err)
235 }
236 return c.clone(ctx, path, url)
237}
238
239func (c *GoGitMirrorClient) clone(ctx context.Context, path, url string) error {
240 _, err := git.PlainCloneContext(ctx, path, true, &git.CloneOptions{
241 URL: url,
242 Mirror: true,
243 })
244 if err != nil && !errors.Is(err, transport.ErrEmptyRemoteRepository) {
245 return fmt.Errorf("cloning repo: %w", err)
246 }
247 return nil
248}
249
250func (c *GoGitMirrorClient) Fetch(ctx context.Context, repo *models.Repo) error {
251 path := c.MakeRepoPath(repo)
252 url, err := c.MakeRepoRemoteUrl(repo)
253 if err != nil {
254 return fmt.Errorf("constructing repo remote url: %w", err)
255 }
256
257 return c.fetch(ctx, path, url)
258}
259
260func (c *GoGitMirrorClient) fetch(ctx context.Context, path, url string) error {
261 gr, err := git.PlainOpen(path)
262 if err != nil {
263 return fmt.Errorf("opening local repo: %w", err)
264 }
265 if err := gr.FetchContext(ctx, &git.FetchOptions{
266 RemoteURL: url,
267 RefSpecs: []gitconfig.RefSpec{gitconfig.RefSpec("+refs/*:refs/*")},
268 Force: true,
269 Prune: true,
270 }); err != nil {
271 return fmt.Errorf("fetching reppo: %w", err)
272 }
273 return nil
274}
275
276func (c *GoGitMirrorClient) Sync(ctx context.Context, repo *models.Repo) error {
277 path := c.MakeRepoPath(repo)
278 url, err := c.MakeRepoRemoteUrl(repo)
279 if err != nil {
280 return fmt.Errorf("constructing repo remote url: %w", err)
281 }
282
283 exist, err := isDir(path)
284 if err != nil {
285 return fmt.Errorf("checking repo path: %w", err)
286 }
287 if !exist {
288 if err := c.clone(ctx, path, url); err != nil {
289 return fmt.Errorf("cloning repo: %w", err)
290 }
291 } else {
292 if err := c.fetch(ctx, path, url); err != nil {
293 return fmt.Errorf("fetching repo: %w", err)
294 }
295 }
296 return nil
297}
298
299func isDir(path string) (bool, error) {
300 info, err := os.Stat(path)
301 if err == nil && info.IsDir() {
302 return true, nil
303 }
304 if os.IsNotExist(err) {
305 return false, nil
306 }
307 return false, err
308}