Monorepo for Tangled tangled.org
1

Configure Feed

Select the types of activity you want to include in your feed.

core / knotmirror / git.go
11 kB 386 lines
1package knotmirror 2 3import ( 4 "context" 5 "errors" 6 "fmt" 7 "log" 8 "net/url" 9 "os" 10 "os/exec" 11 "path/filepath" 12 "regexp" 13 "strings" 14 "time" 15 16 "github.com/go-git/go-git/v5" 17 gitconfig "github.com/go-git/go-git/v5/config" 18 "github.com/go-git/go-git/v5/plumbing/transport" 19 "tangled.org/core/knotmirror/models" 20) 21 22type branch struct { 23 Name string `json:"name"` 24 Version string `json:"version"` 25} 26 27type GitMirrorManager interface { 28 Exist(repo *models.Repo) (bool, error) 29 // Clone clones the repository as a mirror 30 Clone(ctx context.Context, repo *models.Repo) error 31 // Fetch fetches the repository 32 Fetch(ctx context.Context, repo *models.Repo) error 33 // Sync mirrors the repository. It will clone the repository if repository doesn't exist. 34 Sync(ctx context.Context, repo *models.Repo) error 35 DefaultBranch(ctx context.Context, repo *models.Repo) (branch, error) 36 Delete(repo *models.Repo) error 37} 38 39type CliGitMirrorManager struct { 40 repoBasePath string 41 knotUseSSL bool 42} 43 44func NewCliGitMirrorManager(repoBasePath string, knotUseSSL bool) *CliGitMirrorManager { 45 return &CliGitMirrorManager{ 46 repoBasePath, 47 knotUseSSL, 48 } 49} 50 51var _ GitMirrorManager = new(CliGitMirrorManager) 52 53func (c *CliGitMirrorManager) makeRepoPath(repo *models.Repo) string { 54 return filepath.Join(c.repoBasePath, repo.RepoDid.String()) 55} 56 57func (c *CliGitMirrorManager) Exist(repo *models.Repo) (bool, error) { 58 return isDir(c.makeRepoPath(repo)) 59} 60 61func (c *CliGitMirrorManager) Clone(ctx context.Context, repo *models.Repo) error { 62 path := c.makeRepoPath(repo) 63 url, err := makeRepoRemoteUrl(repo.KnotDomain, repo.RepoIdentifier(), c.knotUseSSL) 64 if err != nil { 65 return fmt.Errorf("constructing repo remote url: %w", err) 66 } 67 return c.clone(ctx, path, url) 68} 69 70func (c *CliGitMirrorManager) clone(ctx context.Context, path, url string) error { 71 cmd := exec.CommandContext(ctx, "git", "clone", "--mirror", url, path) 72 if out, err := cmd.CombinedOutput(); err != nil { 73 if ctx.Err() != nil { 74 return ctx.Err() 75 } 76 msg := string(out) 77 if classification := classifyCliError(msg); classification != nil { 78 return classification 79 } 80 return fmt.Errorf("running 'git clone --mirror %s': %w\n%s", url, err, msg) 81 } 82 writeCommitGraph(ctx, path, 30*time.Second) 83 return nil 84} 85 86func (c *CliGitMirrorManager) Fetch(ctx context.Context, repo *models.Repo) error { 87 path := c.makeRepoPath(repo) 88 url, err := makeRepoRemoteUrl(repo.KnotDomain, repo.RepoIdentifier(), c.knotUseSSL) 89 if err != nil { 90 return fmt.Errorf("constructing repo remote url: %w", err) 91 } 92 return c.fetch(ctx, path, url) 93} 94 95func (c *CliGitMirrorManager) fetch(ctx context.Context, path, url string) error { 96 cmd := exec.CommandContext(ctx, "git", "-C", path, "fetch", "--prune", url, "+refs/*:refs/*") 97 if out, err := cmd.CombinedOutput(); err != nil { 98 if ctx.Err() != nil { 99 return ctx.Err() 100 } 101 return fmt.Errorf("running 'git fetch': %w\n%s", err, string(out)) 102 } 103 104 // TODO(boltless): make this dedicated event instead 105 lsRemoteCmd := exec.CommandContext(ctx, "git", "ls-remote", "--symref", url, "HEAD") 106 out, err := lsRemoteCmd.CombinedOutput() 107 if err != nil { 108 if ctx.Err() != nil { 109 return ctx.Err() 110 } 111 return fmt.Errorf("running 'git ls-remote --symref': %w\n%s", err, string(out)) 112 } 113 114 var headRef string 115 for line := range strings.SplitSeq(string(out), "\n") { 116 if !strings.HasPrefix(line, "ref: ") { 117 continue 118 } 119 fields := strings.Fields(line) 120 if len(fields) >= 2 { 121 headRef = fields[1] 122 break 123 } 124 } 125 if headRef != "" { 126 symrefCmd := exec.CommandContext(ctx, "git", "-C", path, "symbolic-ref", "HEAD", headRef) 127 if out, err := symrefCmd.CombinedOutput(); err != nil { 128 if ctx.Err() != nil { 129 return ctx.Err() 130 } 131 return fmt.Errorf("running 'git symbolic-ref HEAD %s': %w\n%s", headRef, err, string(out)) 132 } 133 } 134 135 writeCommitGraph(ctx, path, 3*time.Second) 136 return nil 137} 138 139func writeCommitGraph(ctx context.Context, path string, timeout time.Duration) { 140 ctx, cancel := context.WithTimeout(ctx, timeout) 141 defer cancel() 142 if err := exec.CommandContext(ctx, "git", "-C", path, "commit-graph", "write", "--reachable", "--split").Run(); err != nil { 143 log.Println("failed to run commit-graph", err) 144 } 145} 146 147func (c *CliGitMirrorManager) Sync(ctx context.Context, repo *models.Repo) error { 148 path := c.makeRepoPath(repo) 149 url, err := makeRepoRemoteUrl(repo.KnotDomain, repo.RepoIdentifier(), c.knotUseSSL) 150 if err != nil { 151 return fmt.Errorf("constructing repo remote url: %w", err) 152 } 153 154 exist, err := isDir(path) 155 if err != nil { 156 return fmt.Errorf("checking repo path: %w", err) 157 } 158 if !exist { 159 if err := c.clone(ctx, path, url); err != nil { 160 return fmt.Errorf("cloning repo: %w", err) 161 } 162 } else { 163 if err := c.fetch(ctx, path, url); err != nil { 164 return fmt.Errorf("fetching repo: %w", err) 165 } 166 } 167 return nil 168} 169 170func (c *CliGitMirrorManager) DefaultBranch(ctx context.Context, repo *models.Repo) (branch, error) { 171 path := c.makeRepoPath(repo) 172 173 nameCmd := exec.CommandContext(ctx, "git", "-C", path, "symbolic-ref", "--short", "HEAD") 174 nameOut, err := nameCmd.Output() 175 if err != nil { 176 return branch{}, err 177 } 178 179 // --verify --quiet exits 1 with no output on an empty repo (unborn HEAD). 180 revCmd := exec.CommandContext(ctx, "git", "-C", path, "rev-parse", "--verify", "--quiet", "HEAD") 181 revOut, err := revCmd.Output() 182 if err != nil { 183 return branch{}, err 184 } 185 186 version := strings.TrimSpace(string(revOut)) 187 if version == "" { 188 return branch{}, errors.New("git: no commits") 189 } 190 191 return branch{ 192 Name: strings.TrimSpace(string(nameOut)), 193 Version: version, 194 }, nil 195} 196 197func (c *CliGitMirrorManager) Delete(repo *models.Repo) error { 198 return os.RemoveAll(c.makeRepoPath(repo)) 199} 200 201var ( 202 ErrDNSFailure = errors.New("git: knot: dns failure (could not resolve host)") 203 ErrCertExpired = errors.New("git: knot: certificate has expired") 204 ErrCertMismatch = errors.New("git: knot: certificate hostname mismatch") 205 ErrTLSHandshake = errors.New("git: knot: tls handshake failure") 206 ErrHTTPStatus = errors.New("git: knot: request url returned error") 207 ErrUnreachable = errors.New("git: knot: could not connect to server") 208 ErrRepoNotFound = errors.New("git: repo: repository not found") 209) 210 211var ( 212 reDNSFailure = regexp.MustCompile(`Could not resolve host:`) 213 reCertExpired = regexp.MustCompile(`SSL certificate OpenSSL verify result: certificate has expired`) 214 reCertMismatch = regexp.MustCompile(`SSL: no alternative certificate subject name matches target hostname`) 215 reTLSHandshake = regexp.MustCompile(`TLS connect error: (.*)`) 216 reHTTPStatus = regexp.MustCompile(`The requested URL returned error: (\d\d\d)`) 217 reUnreachable = regexp.MustCompile(`Could not connect to server`) 218 reRepoNotFound = regexp.MustCompile(`repository '.*?' not found`) 219) 220 221// classifyCliError classifies git cli error message. It will return nil for unknown error messages 222func classifyCliError(stderr string) error { 223 msg := strings.TrimSpace(stderr) 224 if m := reTLSHandshake.FindStringSubmatch(msg); len(m) > 1 { 225 return fmt.Errorf("%w: %s", ErrTLSHandshake, m[1]) 226 } 227 if m := reHTTPStatus.FindStringSubmatch(msg); len(m) > 1 { 228 return fmt.Errorf("%w: %s", ErrHTTPStatus, m[1]) 229 } 230 switch { 231 case reDNSFailure.MatchString(msg): 232 return ErrDNSFailure 233 case reCertExpired.MatchString(msg): 234 return ErrCertExpired 235 case reCertMismatch.MatchString(msg): 236 return ErrCertMismatch 237 case reUnreachable.MatchString(msg): 238 return ErrUnreachable 239 case reRepoNotFound.MatchString(msg): 240 return ErrRepoNotFound 241 } 242 return nil 243} 244 245type GoGitMirrorManager struct { 246 repoBasePath string 247 knotUseSSL bool 248} 249 250func NewGoGitMirrorClient(repoBasePath string, knotUseSSL bool) *GoGitMirrorManager { 251 return &GoGitMirrorManager{ 252 repoBasePath, 253 knotUseSSL, 254 } 255} 256 257var _ GitMirrorManager = new(GoGitMirrorManager) 258 259func (c *GoGitMirrorManager) makeRepoPath(repo *models.Repo) string { 260 return filepath.Join(c.repoBasePath, repo.RepoDid.String()) 261} 262 263func (c *GoGitMirrorManager) Exist(repo *models.Repo) (bool, error) { 264 return isDir(c.makeRepoPath(repo)) 265} 266 267func (c *GoGitMirrorManager) Clone(ctx context.Context, repo *models.Repo) error { 268 path := c.makeRepoPath(repo) 269 url, err := makeRepoRemoteUrl(repo.KnotDomain, repo.RepoIdentifier(), c.knotUseSSL) 270 if err != nil { 271 return fmt.Errorf("constructing repo remote url: %w", err) 272 } 273 return c.clone(ctx, path, url) 274} 275 276func (c *GoGitMirrorManager) clone(ctx context.Context, path, url string) error { 277 _, err := git.PlainCloneContext(ctx, path, true, &git.CloneOptions{ 278 URL: url, 279 Mirror: true, 280 }) 281 if err != nil && !errors.Is(err, transport.ErrEmptyRemoteRepository) { 282 return fmt.Errorf("cloning repo: %w", err) 283 } 284 return nil 285} 286 287func (c *GoGitMirrorManager) Fetch(ctx context.Context, repo *models.Repo) error { 288 path := c.makeRepoPath(repo) 289 url, err := makeRepoRemoteUrl(repo.KnotDomain, repo.RepoIdentifier(), c.knotUseSSL) 290 if err != nil { 291 return fmt.Errorf("constructing repo remote url: %w", err) 292 } 293 294 return c.fetch(ctx, path, url) 295} 296 297func (c *GoGitMirrorManager) fetch(ctx context.Context, path, url string) error { 298 gr, err := git.PlainOpen(path) 299 if err != nil { 300 return fmt.Errorf("opening local repo: %w", err) 301 } 302 if err := gr.FetchContext(ctx, &git.FetchOptions{ 303 RemoteURL: url, 304 RefSpecs: []gitconfig.RefSpec{gitconfig.RefSpec("+refs/*:refs/*")}, 305 Force: true, 306 Prune: true, 307 }); err != nil { 308 return fmt.Errorf("fetching reppo: %w", err) 309 } 310 return nil 311} 312 313func (c *GoGitMirrorManager) Sync(ctx context.Context, repo *models.Repo) error { 314 path := c.makeRepoPath(repo) 315 url, err := makeRepoRemoteUrl(repo.KnotDomain, repo.RepoIdentifier(), c.knotUseSSL) 316 if err != nil { 317 return fmt.Errorf("constructing repo remote url: %w", err) 318 } 319 320 exist, err := isDir(path) 321 if err != nil { 322 return fmt.Errorf("checking repo path: %w", err) 323 } 324 if !exist { 325 if err := c.clone(ctx, path, url); err != nil { 326 return fmt.Errorf("cloning repo: %w", err) 327 } 328 } else { 329 if err := c.fetch(ctx, path, url); err != nil { 330 return fmt.Errorf("fetching repo: %w", err) 331 } 332 } 333 return nil 334} 335 336func (c *GoGitMirrorManager) DefaultBranch(ctx context.Context, repo *models.Repo) (branch, error) { 337 gr, err := git.PlainOpen(c.makeRepoPath(repo)) 338 if err != nil { 339 return branch{}, fmt.Errorf("opening local repo: %w", err) 340 } 341 ref, err := gr.Head() 342 if err != nil { 343 return branch{}, fmt.Errorf("resolving HEAD: %w", err) 344 } 345 return branch{ 346 Name: ref.Name().Short(), 347 Version: ref.Hash().String(), 348 }, nil 349} 350 351func (c *GoGitMirrorManager) Delete(repo *models.Repo) error { 352 return os.RemoveAll(c.makeRepoPath(repo)) 353} 354 355func makeRepoRemoteUrl(knot, repoIdentifier string, knotUseSSL bool) (string, error) { 356 if !strings.Contains(knot, "://") { 357 if knotUseSSL { 358 knot = "https://" + knot 359 } else { 360 knot = "http://" + knot 361 } 362 } 363 364 u, err := url.Parse(knot) 365 if err != nil { 366 return "", err 367 } 368 369 if u.Scheme != "http" && u.Scheme != "https" { 370 return "", fmt.Errorf("unsupported scheme: %s", u.Scheme) 371 } 372 373 u = u.JoinPath(repoIdentifier) 374 return u.String(), nil 375} 376 377func isDir(path string) (bool, error) { 378 info, err := os.Stat(path) 379 if err == nil && info.IsDir() { 380 return true, nil 381 } 382 if os.IsNotExist(err) { 383 return false, nil 384 } 385 return false, err 386}