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