A vibe coded tangled fork which supports pijul.
at 1237bf9f58e4ba5d13d5437f2f82a2078572e229 296 lines 8.8 kB view raw
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}