sigs.k8s.io/release-sdk@v0.11.1-0.20240417074027-8061fb5e4952/git/git.go (about) 1 /* 2 Copyright 2019 The Kubernetes Authors. 3 4 Licensed under the Apache License, Version 2.0 (the "License"); 5 you may not use this file except in compliance with the License. 6 You may obtain a copy of the License at 7 8 http://www.apache.org/licenses/LICENSE-2.0 9 10 Unless required by applicable law or agreed to in writing, software 11 distributed under the License is distributed on an "AS IS" BASIS, 12 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 See the License for the specific language governing permissions and 14 limitations under the License. 15 */ 16 17 package git 18 19 import ( 20 "bufio" 21 "bytes" 22 "errors" 23 "fmt" 24 "io" 25 "math" 26 "net/url" 27 "os" 28 "path/filepath" 29 "regexp" 30 "sort" 31 "strings" 32 "time" 33 34 "github.com/blang/semver/v4" 35 "github.com/go-git/go-git/v5" 36 "github.com/go-git/go-git/v5/config" 37 "github.com/go-git/go-git/v5/plumbing" 38 "github.com/go-git/go-git/v5/plumbing/object" 39 "github.com/go-git/go-git/v5/plumbing/storer" 40 "github.com/sirupsen/logrus" 41 42 "sigs.k8s.io/release-sdk/regex" 43 "sigs.k8s.io/release-utils/command" 44 "sigs.k8s.io/release-utils/util" 45 ) 46 47 const ( 48 // DefaultGithubOrg is the default GitHub org used for Kubernetes project 49 // repos 50 DefaultGithubOrg = "kubernetes" 51 52 // DefaultGithubRepo is the default git repository 53 DefaultGithubRepo = "kubernetes" 54 55 // DefaultGithubReleaseRepo is the default git repository used for 56 // SIG Release 57 DefaultGithubReleaseRepo = "sig-release" 58 59 // DefaultRemote is the default git remote name 60 DefaultRemote = "origin" 61 62 // DefaultRef is the default git reference name 63 DefaultRef = "HEAD" 64 65 // DefaultBranch is the default branch name 66 DefaultBranch = "master" 67 68 // DefaultGitUser is the default user name used for commits. 69 DefaultGitUser = "Kubernetes Release Robot" 70 71 // DefaultGitEmail is the default email used for commits. 72 DefaultGitEmail = "k8s-release-robot@users.noreply.github.com" 73 74 defaultGithubAuthRoot = "git@github.com:" 75 gitExecutable = "git" 76 releaseBranchPrefix = "release-" 77 ) 78 79 // setVerboseTrace enables maximum verbosity output. 80 func setVerboseTrace() error { 81 if err := setVerbose(5, 2, 2, 2, 2, 2, 2, 2); err != nil { 82 return fmt.Errorf("set verbose: %w", err) 83 } 84 return nil 85 } 86 87 // setVerboseDebug enables a higher verbosity output for git. 88 func setVerboseDebug() error { 89 if err := setVerbose(2, 2, 2, 0, 0, 0, 2, 0); err != nil { 90 return fmt.Errorf("set verbose: %w", err) 91 } 92 return nil 93 } 94 95 // setVerbose changes the git verbosity output. 96 func setVerbose( 97 merge, 98 curl, 99 trace, 100 tracePackAccess, 101 tracePacket, 102 tracePerformance, 103 traceSetup, 104 traceShallow uint, 105 ) error { 106 // Possible values taken from: 107 // https://git-scm.com/book/en/v2/Git-Internals-Environment-Variables#Debugging 108 for key, value := range map[string]string{ 109 // Controls the output for the recursive merge strategy. The allowed 110 // values are as follows: 111 // 0 outputs nothing, except possibly a single error message. 112 // 1 shows only conflicts. 113 // 2 also shows file changes. 114 // 3 shows when files are skipped because they haven’t changed. 115 // 4 shows all paths as they are processed. 116 // 5 and above show detailed debugging information. 117 // The default value is 2. 118 "GIT_MERGE_VERBOSITY": fmt.Sprint(merge), 119 120 // Git uses the curl library to do network operations over HTTP, so 121 // GIT_CURL_VERBOSE tells Git to emit all the messages generated by 122 // that library. This is similar to doing curl -v on the command line. 123 "GIT_CURL_VERBOSE": fmt.Sprint(curl), 124 125 // Controls general traces, which don’t fit into any specific category. 126 // This includes the expansion of aliases, and delegation to other 127 // sub-programs. 128 "GIT_TRACE": fmt.Sprint(trace), 129 130 // Controls tracing of packfile access. The first field is the packfile 131 // being accessed, the second is the offset within that file. 132 "GIT_TRACE_PACK_ACCESS": fmt.Sprint(tracePackAccess), 133 134 // Enables packet-level tracing for network operations. 135 "GIT_TRACE_PACKET": fmt.Sprint(tracePacket), 136 137 // Controls logging of performance data. The output shows how long each 138 // particular git invocation takes. 139 "GIT_TRACE_PERFORMANCE": fmt.Sprint(tracePerformance), 140 141 // Shows information about what Git is discovering about the repository 142 // and environment it’s interacting with. 143 "GIT_TRACE_SETUP": fmt.Sprint(traceSetup), 144 145 // Debugging fetching/cloning of shallow repositories. 146 "GIT_TRACE_SHALLOW": fmt.Sprint(traceShallow), 147 } { 148 if err := os.Setenv(key, value); err != nil { 149 return fmt.Errorf("unable to set %s=%s: %w", key, value, err) 150 } 151 } 152 return nil 153 } 154 155 // GetDefaultKubernetesRepoURL returns the default HTTPS repo URL for Kubernetes. 156 // Expected: https://github.com/kubernetes/kubernetes 157 func GetDefaultKubernetesRepoURL() string { 158 return GetKubernetesRepoURL(DefaultGithubOrg, false) 159 } 160 161 // GetKubernetesRepoURL takes a GitHub org and repo, and useSSH as a boolean and 162 // returns a repo URL for Kubernetes. 163 // Expected result is one of the following: 164 // - https://github.com/<org>/kubernetes 165 // - git@github.com:<org>/kubernetes 166 func GetKubernetesRepoURL(org string, useSSH bool) string { 167 if org == "" { 168 org = DefaultGithubOrg 169 } 170 171 return GetRepoURL(org, DefaultGithubRepo, useSSH) 172 } 173 174 // GetRepoURL takes a GitHub org and repo, and useSSH as a boolean and 175 // returns a repo URL for the specified repo. 176 // Expected result is one of the following: 177 // - https://github.com/<org>/<repo> 178 // - git@github.com:<org>/<repo> 179 func GetRepoURL(org, repo string, useSSH bool) (repoURL string) { 180 slug := filepath.Join(org, repo) 181 182 if useSSH { 183 repoURL = fmt.Sprintf("%s%s", defaultGithubAuthRoot, slug) 184 } else { 185 repoURL = (&url.URL{ 186 Scheme: "https", 187 Host: "github.com", 188 Path: slug, 189 }).String() 190 } 191 192 return repoURL 193 } 194 195 // ConfigureGlobalDefaultUserAndEmail globally configures the default git 196 // user and email. 197 func ConfigureGlobalDefaultUserAndEmail() error { 198 if err := filterCommand( 199 "", "config", "--global", "user.name", DefaultGitUser, 200 ).RunSuccess(); err != nil { 201 return fmt.Errorf("configure user name: %w", err) 202 } 203 204 if err := filterCommand( 205 "", "config", "--global", "user.email", DefaultGitEmail, 206 ).RunSuccess(); err != nil { 207 return fmt.Errorf("configure user email: %w", err) 208 } 209 210 return nil 211 } 212 213 // ConfigureGlobalCustomUserAndEmail globally configures a custom git 214 // user and email. 215 func ConfigureGlobalCustomUserAndEmail(gitUser, gitEmail string) error { 216 if err := filterCommand( 217 "", "config", "--global", "user.name", gitUser, 218 ).RunSuccess(); err != nil { 219 return fmt.Errorf("configure user name: %w", err) 220 } 221 222 if err := filterCommand( 223 "", "config", "--global", "user.email", gitEmail, 224 ).RunSuccess(); err != nil { 225 return fmt.Errorf("configure user email: %w", err) 226 } 227 228 return nil 229 } 230 231 // filterCommand returns a command which automatically filters sensitive information. 232 func filterCommand(workdir string, args ...string) *command.Command { 233 // Filter GitHub API keys 234 c, err := command.NewWithWorkDir( 235 workdir, gitExecutable, args..., 236 ).Filter(`(?m)git:[0-9a-zA-Z]{35,40}`, "[REDACTED]") 237 if err != nil { 238 // should never happen 239 logrus.Fatalf("git command creation failed: %v", err) 240 } 241 242 return c 243 } 244 245 // DiscoverResult is the result of a revision discovery 246 type DiscoverResult struct { 247 startSHA, startRev, endSHA, endRev string 248 } 249 250 // StartSHA returns the start SHA for the DiscoverResult 251 func (d *DiscoverResult) StartSHA() string { 252 return d.startSHA 253 } 254 255 // StartRev returns the start revision for the DiscoverResult 256 func (d *DiscoverResult) StartRev() string { 257 return d.startRev 258 } 259 260 // EndSHA returns the end SHA for the DiscoverResult 261 func (d *DiscoverResult) EndSHA() string { 262 return d.endSHA 263 } 264 265 // EndRev returns the end revision for the DiscoverResult 266 func (d *DiscoverResult) EndRev() string { 267 return d.endRev 268 } 269 270 // Remote is a representation of a git remote location 271 type Remote struct { 272 name string 273 urls []string 274 } 275 276 // NewRemote creates a new remote for the provided name and URLs 277 func NewRemote(name string, urls []string) *Remote { 278 return &Remote{name, urls} 279 } 280 281 // Name returns the name of the remote 282 func (r *Remote) Name() string { 283 return r.name 284 } 285 286 // URLs returns all available URLs of the remote 287 func (r *Remote) URLs() []string { 288 return r.urls 289 } 290 291 // Repo is a wrapper for a Kubernetes repository instance 292 type Repo struct { 293 inner Repository 294 worktree Worktree 295 dir string 296 dryRun bool 297 maxRetries int 298 } 299 300 //go:generate go run github.com/maxbrunsfeld/counterfeiter/v6 -generate 301 //go:generate /usr/bin/env bash -c "cat ../scripts/boilerplate/boilerplate.generatego.txt gitfakes/fake_repository.go > gitfakes/_fake_repository.go && mv gitfakes/_fake_repository.go gitfakes/fake_repository.go" 302 //go:generate /usr/bin/env bash -c "cat ../scripts/boilerplate/boilerplate.generatego.txt gitfakes/fake_worktree.go > gitfakes/_fake_worktree.go && mv gitfakes/_fake_worktree.go gitfakes/fake_worktree.go" 303 304 // Repository is the main interface to the git.Repository functionality 305 // 306 //counterfeiter:generate . Repository 307 type Repository interface { 308 CreateTag(string, plumbing.Hash, *git.CreateTagOptions) (*plumbing.Reference, error) 309 Branches() (storer.ReferenceIter, error) 310 CommitObject(plumbing.Hash) (*object.Commit, error) 311 CreateRemote(*config.RemoteConfig) (*git.Remote, error) 312 DeleteRemote(name string) error 313 Push(o *git.PushOptions) error 314 Head() (*plumbing.Reference, error) 315 Remote(string) (*git.Remote, error) 316 Remotes() ([]*git.Remote, error) 317 ResolveRevision(plumbing.Revision) (*plumbing.Hash, error) 318 Tags() (storer.ReferenceIter, error) 319 } 320 321 // Worktree is the main interface to the git.Worktree functionality 322 // 323 //counterfeiter:generate . Worktree 324 type Worktree interface { 325 Add(string) (plumbing.Hash, error) 326 Commit(string, *git.CommitOptions) (plumbing.Hash, error) 327 Checkout(*git.CheckoutOptions) error 328 Status() (git.Status, error) 329 } 330 331 // Dir returns the directory where the repository is stored on disk 332 func (r *Repo) Dir() string { 333 return r.dir 334 } 335 336 // SetDry sets the repo to dry-run mode, which does not modify any remote locations 337 // at all. 338 func (r *Repo) SetDry() { 339 r.dryRun = true 340 } 341 342 // SetWorktree can be used to manually set the repository worktree 343 func (r *Repo) SetWorktree(worktree Worktree) { 344 r.worktree = worktree 345 } 346 347 // SetInnerRepo can be used to manually set the inner repository 348 func (r *Repo) SetInnerRepo(repo Repository) { 349 r.inner = repo 350 } 351 352 // SetMaxRetries defines the number of times, the git client will retry 353 // some operations when timing out or network failures. Setting it to 354 // 0 disables retrying 355 func (r *Repo) SetMaxRetries(numRetries int) { 356 r.maxRetries = numRetries 357 } 358 359 func LSRemoteExec(repoURL string, args ...string) (string, error) { 360 cmdArgs := append([]string{"ls-remote", "--", repoURL}, args...) 361 cmdStatus, err := filterCommand("", cmdArgs...). 362 RunSilentSuccessOutput() 363 if err != nil { 364 return "", fmt.Errorf("failed to execute the ls-remote command: %w", err) 365 } 366 367 return strings.TrimSpace(cmdStatus.Output()), nil 368 } 369 370 // CloneOrOpenDefaultGitHubRepoSSH clones the default Kubernetes GitHub 371 // repository via SSH if the repoPath is empty, otherwise updates it at the 372 // expected repoPath. 373 func CloneOrOpenDefaultGitHubRepoSSH(repoPath string) (*Repo, error) { 374 return CloneOrOpenGitHubRepo( 375 repoPath, DefaultGithubOrg, DefaultGithubRepo, true, 376 ) 377 } 378 379 // CleanCloneGitHubRepo creates a guaranteed fresh checkout of a given repository. The returned *Repo has a Cleanup() 380 // method that should be used to delete the repository on-disk afterwards. 381 func CleanCloneGitHubRepo(owner, repo string, useSSH, updateRepo bool, opts *git.CloneOptions) (*Repo, error) { 382 repoURL := GetRepoURL(owner, repo, useSSH) 383 // The use of a blank string for the repo path triggers special behaviour in CloneOrOpenRepo that causes a true 384 // temporary directory with a random name to be created. 385 return CloneOrOpenRepo("", repoURL, useSSH, updateRepo, opts) 386 } 387 388 // CloneOrOpenGitHubRepo works with a repository in the given directory, or creates one if the directory is empty. The 389 // repo uses the provided GitHub repository via the owner and repo. If useSSH is true, then it will clone the 390 // repository using the defaultGithubAuthRoot. 391 func CloneOrOpenGitHubRepo(repoPath, owner, repo string, useSSH bool) (*Repo, error) { 392 repoURL := GetRepoURL(owner, repo, useSSH) 393 return CloneOrOpenRepo(repoPath, repoURL, useSSH, true, nil) 394 } 395 396 // ShallowCleanCloneGitHubRepo creates a guaranteed fresh checkout of a GitHub 397 // repository. The returned *Repo has a Cleanup() method that should be used to 398 // delete the repository on-disk after it is no longer needed. 399 func ShallowCleanCloneGitHubRepo(owner, repo string, useSSH bool) (*Repo, error) { 400 repoURL := GetRepoURL(owner, repo, useSSH) 401 // Pass a blank string to ensure a temp directory gets created 402 return ShallowCloneOrOpenRepo("", repoURL, useSSH) 403 } 404 405 // ShallowCloneOrOpenGitHubRepo this is the *GitHub* counterpart of 406 // ShallowCloneOrOpenRepo. It works exactly the same but it takes a 407 // GitHub org and repo instead of a URL 408 func ShallowCloneOrOpenGitHubRepo(owner, repoPath string, useSSH bool) (*Repo, error) { 409 repoURL := GetRepoURL(owner, repoPath, useSSH) 410 return ShallowCloneOrOpenRepo(repoPath, repoURL, useSSH) 411 } 412 413 // ShallowCloneOrOpenRepo clones performs a shallow clone of a repository (a 414 // clone of depth 1). It is almost identical to CloneOrOpenRepo. If a 415 // repository already exists in repoPath, then no clone is done and the function 416 // returns the existing repository. 417 func ShallowCloneOrOpenRepo(repoPath, repoURL string, useSSH bool) (*Repo, error) { //nolint: revive 418 return cloneOrOpenRepo(repoPath, repoURL, true, &git.CloneOptions{Depth: 1}) 419 } 420 421 // CloneOrOpenRepo creates a temp directory containing the provided 422 // GitHub repository via the url. 423 // 424 // If a repoPath is given, then the function tries to update the repository. 425 // 426 // The function returns the repository if cloning or updating of the repository 427 // was successful, otherwise an error. 428 func CloneOrOpenRepo(repoPath, repoURL string, useSSH, updateRepo bool, opts *git.CloneOptions) (*Repo, error) { //nolint: revive 429 return cloneOrOpenRepo(repoPath, repoURL, updateRepo, opts) 430 } 431 432 // cloneOrOpenRepo checks that the repoPath exists or creates it before running the 433 // clone operation and connects the clone progress writer to our logging system 434 // if needed. This function is the core of the *CloneOrOpenRepo functions. 435 func cloneOrOpenRepo(repoPath, repoURL string, updateRepository bool, opts *git.CloneOptions) (*Repo, error) { 436 // Ensure we have a directory path 437 targetDir, preexisting, err := ensureRepoPath(repoPath) 438 if err != nil { 439 return nil, fmt.Errorf("ensuring repository path: %w", err) 440 } 441 442 // If the repo already exists, just update it 443 if preexisting { 444 return updateRepo(targetDir, true) 445 } 446 447 if opts == nil { 448 opts = &git.CloneOptions{} 449 } 450 451 progressBuffer := &bytes.Buffer{} 452 progressWriters := []io.Writer{progressBuffer} 453 454 // Preserve any progresswriters already defined 455 if opts.Progress != nil { 456 progressWriters = append(progressWriters, opts.Progress) 457 } 458 459 // Only output the clone progress on debug or trace level, 460 // otherwise it's too boring. 461 logLevel := logrus.StandardLogger().Level 462 if logLevel >= logrus.DebugLevel { 463 progressWriters = append(progressWriters, os.Stderr) 464 } 465 466 opts.Progress = io.MultiWriter(progressWriters...) 467 468 if err := cloneRepository(repoURL, targetDir, opts); err != nil { 469 if logLevel < logrus.DebugLevel { 470 logrus.Errorf( 471 "Clone repository failed. Tracked progress:\n%s", 472 progressBuffer.String(), 473 ) 474 } 475 return nil, err 476 } 477 478 return updateRepo(targetDir, updateRepository) 479 } 480 481 // cloneRepository is a utility function that exposes the bare git clone 482 // operation internally. 483 func cloneRepository(repoURL, repoPath string, opts *git.CloneOptions) error { 484 // We always clone to the repo defined in the arguments 485 if opts == nil { 486 opts = &git.CloneOptions{} 487 } 488 opts.URL = repoURL 489 if _, err := git.PlainClone(repoPath, false, opts); err != nil { 490 return fmt.Errorf("unable to clone repo: %w", err) 491 } 492 return nil 493 } 494 495 // ensureRepoPath makes sure the repository path points to a valid directory. If 496 // the path is an empty string, it will create a temporary directory. If it already 497 // exists it will return exisitingDir set to true, a bool to let other functions 498 // know its ok not to clone it again. 499 func ensureRepoPath(repoPath string) (targetDir string, exisitingDir bool, err error) { 500 if repoPath != "" { 501 logrus.Debugf("Using existing repository path %q", repoPath) 502 _, err := os.Stat(repoPath) 503 504 switch { 505 case err == nil: 506 // The file or directory exists, just try to update the repo 507 return repoPath, true, nil 508 case os.IsNotExist(err): 509 // The directory does not exists, we still have to clone it 510 targetDir = repoPath 511 default: 512 // Something else bad happened 513 return "", false, fmt.Errorf("reading existing directory: %w", err) 514 } 515 } else { 516 // No repoPath given, use a random temp dir instead 517 t, err := os.MkdirTemp("", "k8s-") 518 if err != nil { 519 return "", false, fmt.Errorf("unable to create temp dir: %w", err) 520 } 521 targetDir = t 522 logrus.Debugf("Cloning to temporary directory %q", t) 523 } 524 return targetDir, false, nil 525 } 526 527 // updateRepo tries to open the provided repoPath and fetches the latest 528 // changes from the configured remote location 529 func updateRepo(repoPath string, updateRepository bool) (*Repo, error) { 530 r, err := OpenRepo(repoPath) 531 if err != nil { 532 return nil, err 533 } 534 535 if updateRepository { 536 // Update the repo 537 if err := filterCommand( 538 r.Dir(), "pull", "--rebase", 539 ).RunSilentSuccess(); err != nil { 540 return nil, fmt.Errorf("unable to pull from remote: %w", err) 541 } 542 } 543 544 return r, nil 545 } 546 547 // OpenRepo tries to open the provided repoPath 548 func OpenRepo(repoPath string) (*Repo, error) { 549 if !command.Available(gitExecutable) { 550 return nil, fmt.Errorf( 551 "%s executable is not available in $PATH", gitExecutable, 552 ) 553 } 554 logLevel := logrus.StandardLogger().Level 555 if logLevel == logrus.DebugLevel { 556 logrus.Info("Setting verbose git output (debug)") 557 if err := setVerboseDebug(); err != nil { 558 return nil, fmt.Errorf("set debug output: %w", err) 559 } 560 } else if logLevel == logrus.TraceLevel { 561 logrus.Info("Setting verbose git output (trace)") 562 if err := setVerboseTrace(); err != nil { 563 return nil, fmt.Errorf("set trace output: %w", err) 564 } 565 } 566 567 if strings.HasPrefix(repoPath, "~/") { 568 repoPath = os.Getenv("HOME") + repoPath[1:] 569 logrus.Warnf("Normalizing repository to: %s", repoPath) 570 } 571 572 r, err := git.PlainOpenWithOptions( 573 repoPath, &git.PlainOpenOptions{DetectDotGit: true}, 574 ) 575 if err != nil { 576 return nil, fmt.Errorf("opening repo: %w", err) 577 } 578 579 worktree, err := r.Worktree() 580 if err != nil { 581 return nil, fmt.Errorf("getting repository worktree: %w", err) 582 } 583 584 return &Repo{ 585 inner: r, 586 worktree: worktree, 587 dir: worktree.Filesystem.Root(), 588 }, nil 589 } 590 591 func (r *Repo) Cleanup() error { 592 logrus.Debugf("Deleting %s", r.dir) 593 return os.RemoveAll(r.dir) 594 } 595 596 // RevParseTag parses a git revision and returns a SHA1 on success, otherwise an 597 // error. 598 // If the revision does not match a tag add the remote origin in the revision. 599 func (r *Repo) RevParseTag(rev string) (string, error) { 600 matched, err := regexp.MatchString(`v\d+\.\d+\.\d+.*`, rev) 601 if err != nil { 602 return "", err 603 } 604 if !matched { 605 // Prefix all non-tags the default remote "origin" 606 rev = Remotify(rev) 607 } 608 609 // Try to resolve the rev 610 ref, err := r.inner.ResolveRevision(plumbing.Revision(rev)) 611 if err != nil { 612 return "", err 613 } 614 615 return ref.String(), nil 616 } 617 618 // RevParse parses a git revision and returns a SHA1 on success, otherwise an 619 // error. 620 func (r *Repo) RevParse(rev string) (string, error) { 621 // Try to resolve the rev 622 ref, err := r.inner.ResolveRevision(plumbing.Revision(rev)) 623 if err != nil { 624 return "", err 625 } 626 627 return ref.String(), nil 628 } 629 630 // RevParseTagShort parses a git revision and returns a SHA1 trimmed to the length 631 // 10 on success, otherwise an error. 632 // If the revision does not match a tag add the remote origin in the revision. 633 func (r *Repo) RevParseTagShort(rev string) (string, error) { 634 fullRev, err := r.RevParseTag(rev) 635 if err != nil { 636 return "", err 637 } 638 639 return fullRev[:10], nil 640 } 641 642 // RevParseShort parses a git revision and returns a SHA1 trimmed to the length 643 // 10 on success, otherwise an error. 644 func (r *Repo) RevParseShort(rev string) (string, error) { 645 fullRev, err := r.RevParse(rev) 646 if err != nil { 647 return "", err 648 } 649 650 return fullRev[:10], nil 651 } 652 653 // LatestReleaseBranchMergeBaseToLatest tries to discover the start (latest 654 // v1.x.0 merge base) and end (release-1.(x+1) or DefaultBranch) revision inside the 655 // repository. 656 func (r *Repo) LatestReleaseBranchMergeBaseToLatest() (DiscoverResult, error) { 657 // Find the last non patch version tag, then resolve its revision 658 versions, err := r.latestNonPatchFinalVersions() 659 if err != nil { 660 return DiscoverResult{}, err 661 } 662 version := versions[0] 663 versionTag := util.SemverToTagString(version) 664 logrus.Debugf("Latest non patch version %s", versionTag) 665 666 base, err := r.MergeBase( 667 DefaultBranch, 668 semverToReleaseBranch(version), 669 ) 670 if err != nil { 671 return DiscoverResult{}, err 672 } 673 674 // If a release branch exists for the next version, we use it. Otherwise we 675 // fallback to the DefaultBranch. 676 end, branch, err := r.releaseBranchOrMainRef(version.Major, version.Minor+1) 677 if err != nil { 678 return DiscoverResult{}, err 679 } 680 681 return DiscoverResult{ 682 startSHA: base, 683 startRev: versionTag, 684 endSHA: end, 685 endRev: branch, 686 }, nil 687 } 688 689 func (r *Repo) LatestNonPatchFinalToMinor() (DiscoverResult, error) { 690 // Find the last non patch version tag, then resolve its revision 691 versions, err := r.latestNonPatchFinalVersions() 692 if err != nil { 693 return DiscoverResult{}, err 694 } 695 if len(versions) < 2 { 696 return DiscoverResult{}, errors.New("unable to find two latest non patch versions") 697 } 698 699 latestVersion := versions[0] 700 latestVersionTag := util.SemverToTagString(latestVersion) 701 logrus.Debugf("Latest non patch version %s", latestVersionTag) 702 end, err := r.RevParseTag(latestVersionTag) 703 if err != nil { 704 return DiscoverResult{}, err 705 } 706 707 previousVersion := versions[1] 708 previousVersionTag := util.SemverToTagString(previousVersion) 709 logrus.Debugf("Previous non patch version %s", previousVersionTag) 710 start, err := r.RevParseTag(previousVersionTag) 711 if err != nil { 712 return DiscoverResult{}, err 713 } 714 715 return DiscoverResult{ 716 startSHA: start, 717 startRev: previousVersionTag, 718 endSHA: end, 719 endRev: latestVersionTag, 720 }, nil 721 } 722 723 func (r *Repo) latestNonPatchFinalVersions() ([]semver.Version, error) { 724 latestVersions := []semver.Version{} 725 726 tags, err := r.inner.Tags() 727 if err != nil { 728 return nil, err 729 } 730 731 _ = tags.ForEach(func(t *plumbing.Reference) error { //nolint: errcheck 732 ver, err := util.TagStringToSemver(t.Name().Short()) 733 734 if err == nil { 735 // We're searching for the latest, non patch final tag 736 if ver.Patch == 0 && len(ver.Pre) == 0 { 737 if len(latestVersions) == 0 || ver.GT(latestVersions[0]) { 738 latestVersions = append([]semver.Version{ver}, latestVersions...) 739 } 740 } 741 } 742 return nil 743 }) 744 if len(latestVersions) == 0 { 745 return nil, fmt.Errorf("unable to find latest non patch release") 746 } 747 return latestVersions, nil 748 } 749 750 func (r *Repo) releaseBranchOrMainRef(major, minor uint64) (sha, rev string, err error) { 751 relBranch := fmt.Sprintf("%s%d.%d", releaseBranchPrefix, major, minor) 752 sha, err = r.RevParseTag(relBranch) 753 if err == nil { 754 logrus.Debugf("Found release branch %s", relBranch) 755 return sha, relBranch, nil 756 } 757 758 sha, err = r.RevParseTag(DefaultBranch) 759 if err == nil { 760 logrus.Debugf("No release branch found, using %s", DefaultBranch) 761 return sha, DefaultBranch, nil 762 } 763 764 return "", "", err 765 } 766 767 // HasBranch checks if a branch exists in the repo 768 func (r *Repo) HasBranch(branch string) (branchExists bool, err error) { 769 logrus.Infof("Verifying %s branch exists in the repo", branch) 770 771 branches, err := r.inner.Branches() 772 if err != nil { 773 return branchExists, fmt.Errorf("getting branches from repository: %w", err) 774 } 775 776 branchExists = false 777 if err := branches.ForEach(func(ref *plumbing.Reference) error { 778 if ref.Name().Short() == branch { 779 logrus.Infof("Branch %s found in the repository", branch) 780 branchExists = true 781 } 782 return nil 783 }); err != nil { 784 return branchExists, fmt.Errorf("iterating branches to check for existence: %w", err) 785 } 786 return branchExists, nil 787 } 788 789 // HasRemoteBranch takes a branch string and verifies that it exists 790 // on the default remote 791 func (r *Repo) HasRemoteBranch(branch string) (branchExists bool, err error) { 792 logrus.Infof("Verifying %s branch exists on the remote", branch) 793 794 branches, err := r.RemoteBranches() 795 if err != nil { 796 return false, fmt.Errorf("get remote branches: %w", err) 797 } 798 799 for _, remoteBranch := range branches { 800 if remoteBranch == branch { 801 logrus.Infof("Found branch %s", branch) 802 return true, nil 803 } 804 } 805 logrus.Infof("Branch %s not found", branch) 806 return false, nil 807 } 808 809 // RemoteBranches returns a list of all remotely available branches. 810 func (r *Repo) RemoteBranches() (branches []string, err error) { 811 remote, err := r.inner.Remote(DefaultRemote) 812 if err != nil { 813 return nil, NewNetworkError(err) 814 } 815 var refs []*plumbing.Reference 816 for i := r.maxRetries + 1; i > 0; i-- { 817 // We can then use every Remote functions to retrieve wanted information 818 refs, err = remote.List(&git.ListOptions{}) 819 if err == nil { 820 break 821 } 822 logrus.Warn("Could not list references on the remote repository.") 823 // Convert to network error to see if we can retry the push 824 err = NewNetworkError(err) 825 if !err.(NetworkError).CanRetry() || r.maxRetries == 0 || i == 1 { 826 return nil, err 827 } 828 waitTime := math.Pow(2, float64(r.maxRetries-i)) 829 logrus.Errorf( 830 "Error listing remote references (will retry %d more times in %.0f secs): %s", 831 i-1, waitTime, err.Error(), 832 ) 833 time.Sleep(time.Duration(waitTime) * time.Second) 834 } 835 836 for _, ref := range refs { 837 if ref.Name().IsBranch() { 838 branches = append(branches, ref.Name().Short()) 839 } 840 } 841 return branches, nil 842 } 843 844 // Checkout can be used to checkout any revision inside the repository 845 func (r *Repo) Checkout(rev string, args ...string) error { 846 cmdArgs := append([]string{"checkout", rev}, args...) 847 return command. 848 NewWithWorkDir(r.Dir(), gitExecutable, cmdArgs...). 849 RunSilentSuccess() 850 } 851 852 // IsReleaseBranch returns true if the provided branch is a Kubernetes release 853 // branch 854 func IsReleaseBranch(branch string) bool { 855 if !regex.BranchRegex.MatchString(branch) { 856 logrus.Warnf("%s is not a release branch", branch) 857 return false 858 } 859 860 return true 861 } 862 863 func (r *Repo) MergeBase(from, to string) (string, error) { 864 mainRef := Remotify(from) 865 releaseRef := Remotify(to) 866 867 logrus.Debugf("MainRef: %s, releaseRef: %s", mainRef, releaseRef) 868 869 commitRevs := []string{mainRef, releaseRef} 870 var res []*object.Commit 871 872 hashes := []*plumbing.Hash{} 873 for _, rev := range commitRevs { 874 hash, err := r.inner.ResolveRevision(plumbing.Revision(rev)) 875 if err != nil { 876 return "", err 877 } 878 hashes = append(hashes, hash) 879 } 880 881 commits := []*object.Commit{} 882 for _, hash := range hashes { 883 commit, err := r.inner.CommitObject(*hash) 884 if err != nil { 885 return "", err 886 } 887 commits = append(commits, commit) 888 } 889 890 res, err := commits[0].MergeBase(commits[1]) 891 if err != nil { 892 return "", err 893 } 894 895 if len(res) == 0 { 896 return "", fmt.Errorf("could not find a merge base between %s and %s", from, to) 897 } 898 899 mergeBase := res[0].Hash.String() 900 logrus.Infof("Merge base is %s", mergeBase) 901 902 return mergeBase, nil 903 } 904 905 // Remotify returns the name prepended with the default remote 906 func Remotify(name string) string { 907 split := strings.Split(name, "/") 908 if len(split) > 1 { 909 return name 910 } 911 return fmt.Sprintf("%s/%s", DefaultRemote, name) 912 } 913 914 // Merge does a git merge into the current branch from the provided one 915 func (r *Repo) Merge(from string) error { 916 if err := filterCommand( 917 r.Dir(), "merge", "-X", "ours", from, 918 ).RunSilentSuccess(); err != nil { 919 return fmt.Errorf("run git merge: %w", err) 920 } 921 return nil 922 } 923 924 // Push does push the specified branch to the default remote, but only if the 925 // repository is not in dry run mode 926 func (r *Repo) Push(remoteBranch string) (err error) { 927 args := []string{"push"} 928 if r.dryRun { 929 logrus.Infof("Won't push due to dry run repository") 930 args = append(args, "--dry-run") 931 } 932 args = append(args, DefaultRemote, remoteBranch) 933 934 for i := r.maxRetries + 1; i > 0; i-- { 935 if err = filterCommand(r.Dir(), args...).RunSilentSuccess(); err == nil { 936 return nil 937 } 938 // Convert to network error to see if we can retry the push 939 err = NewNetworkError(err) 940 if !err.(NetworkError).CanRetry() || r.maxRetries == 0 { 941 return err 942 } 943 waitTime := math.Pow(2, float64(r.maxRetries-i)) 944 logrus.Errorf( 945 "Error pushing %s (will retry %d more times in %.0f secs): %s", 946 remoteBranch, i-1, waitTime, err.Error(), 947 ) 948 time.Sleep(time.Duration(waitTime) * time.Second) 949 } 950 return fmt.Errorf("trying to push %s %d times: %w", remoteBranch, r.maxRetries, err) 951 } 952 953 // Head retrieves the current repository HEAD as a string 954 func (r *Repo) Head() (string, error) { 955 ref, err := r.inner.Head() 956 if err != nil { 957 return "", err 958 } 959 return ref.Hash().String(), nil 960 } 961 962 // LatestPatchToPatch tries to discover the start (latest v1.x.[x-1]) and 963 // end (latest v1.x.x) revision inside the repository for the specified release 964 // branch. 965 func (r *Repo) LatestPatchToPatch(branch string) (DiscoverResult, error) { 966 latestTag, err := r.LatestTagForBranch(branch) 967 if err != nil { 968 return DiscoverResult{}, err 969 } 970 971 if len(latestTag.Pre) > 0 && latestTag.Patch > 0 { 972 latestTag.Patch-- 973 latestTag.Pre = nil 974 } 975 976 if latestTag.Patch == 0 { 977 return DiscoverResult{}, fmt.Errorf( 978 "found non-patch version %v as latest tag on branch %s", 979 latestTag, branch, 980 ) 981 } 982 983 prevTag := semver.Version{ 984 Major: latestTag.Major, 985 Minor: latestTag.Minor, 986 Patch: latestTag.Patch - 1, 987 } 988 989 logrus.Debugf("Parsing latest tag %s%v", util.TagPrefix, latestTag) 990 latestVersionTag := util.SemverToTagString(latestTag) 991 end, err := r.RevParseTag(latestVersionTag) 992 if err != nil { 993 return DiscoverResult{}, fmt.Errorf("parsing version %v: %w", latestTag, err) 994 } 995 996 logrus.Debugf("Parsing previous tag %s%v", util.TagPrefix, prevTag) 997 previousVersionTag := util.SemverToTagString(prevTag) 998 start, err := r.RevParseTag(previousVersionTag) 999 if err != nil { 1000 return DiscoverResult{}, fmt.Errorf("parsing previous version %v: %w", prevTag, err) 1001 } 1002 1003 return DiscoverResult{ 1004 startSHA: start, 1005 startRev: previousVersionTag, 1006 endSHA: end, 1007 endRev: latestVersionTag, 1008 }, nil 1009 } 1010 1011 // LatestPatchToLatest tries to discover the start (latest v1.x.x]) and 1012 // end (release-1.x or DefaultBranch) revision inside the repository for the specified release 1013 // branch. 1014 func (r *Repo) LatestPatchToLatest(branch string) (DiscoverResult, error) { 1015 latestTag, err := r.LatestTagForBranch(branch) 1016 if err != nil { 1017 return DiscoverResult{}, err 1018 } 1019 1020 if len(latestTag.Pre) > 0 && latestTag.Patch > 0 { 1021 latestTag.Patch-- 1022 latestTag.Pre = nil 1023 } 1024 1025 logrus.Debugf("Parsing latest tag %s%v", util.TagPrefix, latestTag) 1026 latestVersionTag := util.SemverToTagString(latestTag) 1027 start, err := r.RevParseTag(latestVersionTag) 1028 if err != nil { 1029 return DiscoverResult{}, fmt.Errorf("parsing version %v: %w", latestTag, err) 1030 } 1031 1032 // If a release branch exists for the latest version, we use it. Otherwise we 1033 // fallback to the DefaultBranch. 1034 end, branch, err := r.releaseBranchOrMainRef(latestTag.Major, latestTag.Minor) 1035 if err != nil { 1036 return DiscoverResult{}, fmt.Errorf("getting release branch for %v: %w", latestTag, err) 1037 } 1038 1039 return DiscoverResult{ 1040 startSHA: start, 1041 startRev: latestVersionTag, 1042 endSHA: end, 1043 endRev: branch, 1044 }, nil 1045 } 1046 1047 // LatestTagForBranch returns the latest available semver tag for a given branch 1048 func (r *Repo) LatestTagForBranch(branch string) (tag semver.Version, err error) { 1049 tags, err := r.TagsForBranch(branch) 1050 if err != nil { 1051 return tag, err 1052 } 1053 if len(tags) == 0 { 1054 return tag, errors.New("no tags found on branch") 1055 } 1056 1057 tag, err = util.TagStringToSemver(tags[0]) 1058 if err != nil { 1059 return tag, err 1060 } 1061 1062 return tag, nil 1063 } 1064 1065 // PreviousTag tries to find the previous tag for a provided branch and errors 1066 // on any failure 1067 func (r *Repo) PreviousTag(tag, branch string) (string, error) { 1068 tags, err := r.TagsForBranch(branch) 1069 if err != nil { 1070 return "", err 1071 } 1072 1073 idx := -1 1074 for i, t := range tags { 1075 if t == tag { 1076 idx = i 1077 break 1078 } 1079 } 1080 if idx == -1 { 1081 return "", errors.New("could not find specified tag in branch") 1082 } 1083 if len(tags) < idx+1 { 1084 return "", errors.New("unable to find previous tag") 1085 } 1086 1087 return tags[idx+1], nil 1088 } 1089 1090 // TagsForBranch returns a list of tags for the provided branch sorted by 1091 // creation date 1092 func (r *Repo) TagsForBranch(branch string) (res []string, err error) { 1093 previousBranch, err := r.CurrentBranch() 1094 if err != nil { 1095 return nil, fmt.Errorf("retrieving current branch: %w", err) 1096 } 1097 if err := r.Checkout(branch); err != nil { 1098 return nil, fmt.Errorf("checking out %s: %w", branch, err) 1099 } 1100 defer func() { err = r.Checkout(previousBranch) }() 1101 1102 status, err := filterCommand( 1103 r.Dir(), "tag", "--sort=creatordate", "--merged", 1104 ).RunSilentSuccessOutput() 1105 if err != nil { 1106 return nil, fmt.Errorf("retrieving merged tags for branch %s: %w", branch, err) 1107 } 1108 1109 tags := strings.Fields(status.Output()) 1110 sort.Sort(sort.Reverse(sort.StringSlice(tags))) 1111 1112 return tags, nil 1113 } 1114 1115 // Tags returns a list of tags for the repository. 1116 func (r *Repo) Tags() (res []string, err error) { 1117 tags, err := r.inner.Tags() 1118 if err != nil { 1119 return nil, fmt.Errorf("get tags: %w", err) 1120 } 1121 _ = tags.ForEach(func(t *plumbing.Reference) error { //nolint: errcheck 1122 res = append(res, t.Name().Short()) 1123 return nil 1124 }) 1125 return res, nil 1126 } 1127 1128 // Add adds a file to the staging area of the repo 1129 func (r *Repo) Add(filename string) error { 1130 if err := filterCommand( 1131 r.Dir(), "add", filename, 1132 ).RunSilentSuccess(); err != nil { 1133 return fmt.Errorf("adding file %s to repository: %w", filename, err) 1134 } 1135 return nil 1136 } 1137 1138 // GetUserName Reads the local user's name from the git configuration 1139 func GetUserName() (string, error) { 1140 // Retrieve username from git 1141 userName, err := filterCommand( 1142 "", "config", "--get", "user.name", 1143 ).RunSilentSuccessOutput() 1144 if err != nil { 1145 return "", fmt.Errorf("reading the user name from git: %w", err) 1146 } 1147 return userName.OutputTrimNL(), nil 1148 } 1149 1150 // GetUserEmail reads the user's name from git 1151 func GetUserEmail() (string, error) { 1152 userEmail, err := filterCommand( 1153 "", "config", "--get", "user.email", 1154 ).RunSilentSuccessOutput() 1155 if err != nil { 1156 return "", fmt.Errorf("reading the user's email from git: %w", err) 1157 } 1158 return userEmail.OutputTrimNL(), nil 1159 } 1160 1161 // UserCommit makes a commit using the local user's config as well as adding 1162 // the Signed-off-by line to the commit message 1163 func (r *Repo) UserCommit(msg string) error { 1164 // Retrieve username and mail 1165 userName, err := GetUserName() 1166 if err != nil { 1167 return fmt.Errorf("getting the user's name: %w", err) 1168 } 1169 1170 userEmail, err := GetUserEmail() 1171 if err != nil { 1172 return fmt.Errorf("getting the user's email: %w", err) 1173 } 1174 1175 // Add signed-off-by line 1176 msg += fmt.Sprintf("\n\nSigned-off-by: %s <%s>", userName, userEmail) 1177 1178 if err := r.CommitWithOptions(msg, &git.CommitOptions{ 1179 Author: &object.Signature{ 1180 Name: userName, 1181 Email: userEmail, 1182 When: time.Now(), 1183 }, 1184 }); err != nil { 1185 return fmt.Errorf("commit changes: %w", err) 1186 } 1187 1188 return nil 1189 } 1190 1191 // Commit commits the current repository state 1192 func (r *Repo) Commit(msg string) error { 1193 return r.CommitWithOptions( 1194 msg, 1195 &git.CommitOptions{ 1196 Author: &object.Signature{ 1197 Name: DefaultGitUser, 1198 Email: DefaultGitEmail, 1199 When: time.Now(), 1200 }, 1201 }, 1202 ) 1203 } 1204 1205 // CommitWithOptions commits the current repository state 1206 func (r *Repo) CommitWithOptions(msg string, options *git.CommitOptions) error { 1207 if _, err := r.worktree.Commit(msg, options); err != nil { 1208 return err 1209 } 1210 return nil 1211 } 1212 1213 // CommitEmpty commits an empty commit into the repository 1214 func (r *Repo) CommitEmpty(msg string) error { 1215 return command. 1216 NewWithWorkDir(r.Dir(), gitExecutable, 1217 "commit", "--allow-empty", "-m", msg, 1218 ). 1219 RunSilentSuccess() 1220 } 1221 1222 // Tag creates a new annotated tag for the provided `name` and `message`. 1223 func (r *Repo) Tag(name, message string) error { 1224 head, err := r.inner.Head() 1225 if err != nil { 1226 return err 1227 } 1228 if _, err := r.inner.CreateTag( 1229 name, 1230 head.Hash(), 1231 &git.CreateTagOptions{ 1232 Tagger: &object.Signature{ 1233 Name: DefaultGitUser, 1234 Email: DefaultGitEmail, 1235 When: time.Now(), 1236 }, 1237 Message: message, 1238 }); err != nil { 1239 return err 1240 } 1241 return nil 1242 } 1243 1244 // CurrentBranch returns the current branch of the repository or an error in 1245 // case of any failure 1246 func (r *Repo) CurrentBranch() (branch string, err error) { 1247 branches, err := r.inner.Branches() 1248 if err != nil { 1249 return "", err 1250 } 1251 1252 head, err := r.inner.Head() 1253 if err != nil { 1254 return "", err 1255 } 1256 1257 if err := branches.ForEach(func(ref *plumbing.Reference) error { 1258 if ref.Hash() == head.Hash() { 1259 branch = ref.Name().Short() 1260 return nil 1261 } 1262 1263 return nil 1264 }); err != nil { 1265 return "", err 1266 } 1267 1268 return branch, nil 1269 } 1270 1271 // Rm removes files from the repository 1272 func (r *Repo) Rm(force bool, files ...string) error { 1273 args := []string{"rm"} 1274 if force { 1275 args = append(args, "-f") 1276 } 1277 args = append(args, files...) 1278 1279 return command. 1280 NewWithWorkDir(r.Dir(), gitExecutable, args...). 1281 RunSilentSuccess() 1282 } 1283 1284 // Remotes lists the currently available remotes for the repository 1285 func (r *Repo) Remotes() (res []*Remote, err error) { 1286 remotes, err := r.inner.Remotes() 1287 if err != nil { 1288 return nil, fmt.Errorf("unable to list remotes: %w", err) 1289 } 1290 1291 // Sort the remotes by their name which is not always the case 1292 sort.Slice(remotes, func(i, j int) bool { 1293 return remotes[i].Config().Name < remotes[j].Config().Name 1294 }) 1295 1296 for _, remote := range remotes { 1297 cfg := remote.Config() 1298 res = append(res, &Remote{name: cfg.Name, urls: cfg.URLs}) 1299 } 1300 1301 return res, nil 1302 } 1303 1304 // HasRemote checks if the provided remote `name` is available and matches the 1305 // expected `url` 1306 func (r *Repo) HasRemote(name, expectedURL string) bool { 1307 remotes, err := r.Remotes() 1308 if err != nil { 1309 logrus.Warnf("Unable to get repository remotes: %v", err) 1310 return false 1311 } 1312 1313 for _, remote := range remotes { 1314 if remote.Name() == name { 1315 for _, url := range remote.URLs() { 1316 if url == expectedURL { 1317 return true 1318 } 1319 } 1320 } 1321 } 1322 1323 return false 1324 } 1325 1326 // AddRemote adds a new remote to the current working tree 1327 func (r *Repo) AddRemote(name, owner, repo string, useSSH bool) error { 1328 repoURL := GetRepoURL(owner, repo, useSSH) 1329 args := []string{"remote", "add", name, repoURL} 1330 return command. 1331 NewWithWorkDir(r.Dir(), gitExecutable, args...). 1332 RunSilentSuccess() 1333 } 1334 1335 // PushToRemote push the current branch to a specified remote, but only if the 1336 // repository is not in dry run mode 1337 func (r *Repo) PushToRemote(remote, remoteBranch string) error { 1338 args := []string{"push", "--set-upstream"} 1339 if r.dryRun { 1340 logrus.Infof("Won't push due to dry run repository") 1341 args = append(args, "--dry-run") 1342 } 1343 args = append(args, remote, remoteBranch) 1344 1345 return filterCommand(r.Dir(), args...).RunSuccess() 1346 } 1347 1348 // PushToRemote push the current branch to a specified remote, but only if the 1349 // repository is not in dry run mode 1350 func (r *Repo) PushToRemoteWithOptions(pushOptions *git.PushOptions) error { 1351 return r.inner.Push(pushOptions) 1352 } 1353 1354 // LsRemote can be used to run `git ls-remote` with the provided args on the 1355 // repository 1356 func (r *Repo) LsRemote(args ...string) (output string, err error) { 1357 for i := r.maxRetries + 1; i > 0; i-- { 1358 params := []string{} 1359 params = append(params, "--") 1360 params = append(params, args...) 1361 output, err = r.runGitCmd("ls-remote", params...) 1362 if err == nil { 1363 return output, nil 1364 } 1365 err = NewNetworkError(err) 1366 if !err.(NetworkError).CanRetry() || r.maxRetries == 0 || i == 1 { 1367 return "", err 1368 } 1369 1370 waitTime := math.Pow(2, float64(r.maxRetries-i)) 1371 logrus.Errorf( 1372 "Executing ls-remote (will retry %d more times in %.0f secs): %s", 1373 i-1, waitTime, err.Error(), 1374 ) 1375 time.Sleep(time.Duration(waitTime) * time.Second) 1376 } 1377 return "", err 1378 } 1379 1380 // Branch can be used to run `git branch` with the provided args on the 1381 // repository 1382 func (r *Repo) Branch(args ...string) (string, error) { 1383 return r.runGitCmd("branch", args...) 1384 } 1385 1386 // runGitCmd runs the provided command in the repository root and appends the 1387 // args. The command will run silently and return the captured output or an 1388 // error in case of any failure. 1389 func (r *Repo) runGitCmd(cmd string, args ...string) (string, error) { 1390 cmdArgs := append([]string{cmd}, args...) 1391 res, err := filterCommand(r.Dir(), cmdArgs...).RunSilentSuccessOutput() 1392 if err != nil { 1393 return "", fmt.Errorf("running git %s: %w", cmd, err) 1394 } 1395 return res.OutputTrimNL(), nil 1396 } 1397 1398 // IsDirty returns true if the worktree status is not clean. It can also error 1399 // if the worktree status is not retrievable. 1400 func (r *Repo) IsDirty() (bool, error) { 1401 status, err := r.Status() 1402 if err != nil { 1403 return false, fmt.Errorf("retrieving worktree status: %w", err) 1404 } 1405 return !status.IsClean(), nil 1406 } 1407 1408 // RemoteTags return the tags that currently exist in the 1409 func (r *Repo) RemoteTags() (tags []string, err error) { 1410 logrus.Debug("Listing remote tags with ls-remote") 1411 output, err := r.LsRemote(DefaultRemote) 1412 if err != nil { 1413 return tags, fmt.Errorf("while listing tags using ls-remote: %w", err) 1414 } 1415 const gitTagPreRef = "refs/tags/" 1416 tags = make([]string, 0) 1417 scanner := bufio.NewScanner(strings.NewReader(output)) 1418 scanner.Split(bufio.ScanWords) 1419 for scanner.Scan() { 1420 if strings.HasPrefix(scanner.Text(), gitTagPreRef) { 1421 tags = append(tags, strings.TrimPrefix(scanner.Text(), gitTagPreRef)) 1422 } 1423 } 1424 logrus.Debugf("Remote repository contains %d tags", len(tags)) 1425 return tags, nil 1426 } 1427 1428 // HasRemoteTag Checks if the default remote already has a tag 1429 func (r *Repo) HasRemoteTag(tag string) (hasTag bool, err error) { 1430 remoteTags, err := r.RemoteTags() 1431 if err != nil { 1432 return hasTag, fmt.Errorf("getting tags to check if tag exists: %w", err) 1433 } 1434 for _, remoteTag := range remoteTags { 1435 if tag == remoteTag { 1436 logrus.Infof("Tag %s found in default remote", tag) 1437 return true, nil 1438 } 1439 } 1440 return false, nil 1441 } 1442 1443 // SetURL can be used to overwrite the URL for a remote 1444 func (r *Repo) SetURL(remote, newURL string) error { 1445 if err := r.inner.DeleteRemote(remote); err != nil { 1446 return fmt.Errorf("delete remote: %w", err) 1447 } 1448 if _, err := r.inner.CreateRemote(&config.RemoteConfig{ 1449 Name: remote, 1450 URLs: []string{newURL}, 1451 }); err != nil { 1452 return fmt.Errorf("create remote: %w", err) 1453 } 1454 return nil 1455 } 1456 1457 // Status reads and returns the Status object from the repository 1458 func (r *Repo) Status() (*git.Status, error) { 1459 status, err := r.worktree.Status() 1460 if err != nil { 1461 return nil, fmt.Errorf("getting the repository status: %w", err) 1462 } 1463 return &status, nil 1464 } 1465 1466 // ShowLastCommit is a simple function that runs git show and returns the 1467 // last commit in the log 1468 func (r *Repo) ShowLastCommit() (logData string, err error) { 1469 logData, err = r.runGitCmd("show") 1470 if err != nil { 1471 return logData, fmt.Errorf("getting last commit log: %w", err) 1472 } 1473 return logData, nil 1474 } 1475 1476 // LastCommitSha returns the sha of the last commit in the repository 1477 func (r *Repo) LastCommitSha() (string, error) { 1478 shaval, err := r.runGitCmd("log", "--pretty=format:'%H'", "-n1") 1479 if err != nil { 1480 return "", fmt.Errorf("trying to retrieve the last commit sha: %w", err) 1481 } 1482 return shaval, nil 1483 } 1484 1485 // FetchRemote gets the objects from the specified remote. It returns true as 1486 // first argument if something has been fetched remotely. 1487 func (r *Repo) FetchRemote(remoteName string) (bool, error) { 1488 if remoteName == "" { 1489 return false, errors.New("error fetching, remote repository name is empty") 1490 } 1491 // Verify the remote exists 1492 remotes, err := r.Remotes() 1493 if err != nil { 1494 return false, fmt.Errorf("getting repository remotes: %w", err) 1495 } 1496 1497 remoteExists := false 1498 for _, remote := range remotes { 1499 if remote.Name() == remoteName { 1500 remoteExists = true 1501 break 1502 } 1503 } 1504 if !remoteExists { 1505 return false, errors.New("cannot fetch repository, the specified remote does not exist") 1506 } 1507 1508 res, err := filterCommand(r.Dir(), "fetch", remoteName).RunSilentSuccessOutput() 1509 if err != nil { 1510 return false, fmt.Errorf("fetching objects from %s: %w", remoteName, err) 1511 } 1512 // git fetch outputs on stderr 1513 output := strings.TrimSpace(res.Error()) 1514 logrus.Debugf("Fetch result: %s", output) 1515 return output != "", nil 1516 } 1517 1518 // Rebase calls rebase on the current repo to the specified branch 1519 func (r *Repo) Rebase(branch string) error { 1520 if branch == "" { 1521 return errors.New("cannot rebase repository, branch is empty") 1522 } 1523 logrus.Infof("Rebasing repository to %s", branch) 1524 _, err := r.runGitCmd("rebase", branch) 1525 // If we get an error, try to interpret it to make more sense 1526 if err != nil { 1527 return fmt.Errorf("rebasing repository: %w", err) 1528 } 1529 return nil 1530 } 1531 1532 // ParseRepoSlug parses a repository string and return the organization and repository name/ 1533 func ParseRepoSlug(repoSlug string) (org, repo string, err error) { 1534 match, err := regexp.MatchString(`(?i)^[a-z0-9-/]+$`, repoSlug) 1535 if err != nil { 1536 return "", "", fmt.Errorf("checking repository slug: %w", err) 1537 } 1538 if !match { 1539 return "", "", errors.New("repository slug contains invalid characters") 1540 } 1541 1542 parts := strings.Split(repoSlug, "/") 1543 if len(parts) > 2 { 1544 return "", "", errors.New("string is not a well formed org/repo slug") 1545 } 1546 org = parts[0] 1547 if len(parts) > 1 { 1548 repo = parts[1] 1549 } 1550 return org, repo, nil 1551 } 1552 1553 // NewNetworkError creates a new NetworkError 1554 func NewNetworkError(err error) NetworkError { 1555 gerror := NetworkError{ 1556 error: err, 1557 } 1558 return gerror 1559 } 1560 1561 // NetworkError is a wrapper for the error class 1562 type NetworkError struct { 1563 error 1564 } 1565 1566 // CanRetry tells if an error can be retried 1567 func (e NetworkError) CanRetry() bool { 1568 // We consider these strings as part of errors we can retry 1569 retryMessages := []string{ 1570 "dial tcp", "read udp", "connection refused", 1571 "ssh: connect to host", "Could not read from remote", 1572 } 1573 1574 // If any of them are in the error message, we consider it temporary 1575 for _, message := range retryMessages { 1576 if strings.Contains(e.Error(), message) { 1577 return true 1578 } 1579 } 1580 1581 // Otherwise permanent 1582 return false 1583 } 1584 1585 // LatestReleaseBranch determines the latest release-x.y branch of the repo. 1586 func (r *Repo) LatestReleaseBranch() (string, error) { 1587 branches, err := r.RemoteBranches() 1588 if err != nil { 1589 return "", fmt.Errorf("get remote branches: %w", err) 1590 } 1591 1592 var latest semver.Version 1593 for _, branch := range branches { 1594 if strings.HasPrefix(branch, releaseBranchPrefix) { 1595 version := strings.TrimPrefix(branch, releaseBranchPrefix) + ".0" 1596 1597 parsed, err := semver.Parse(version) 1598 if err != nil { 1599 logrus.Debugf("Unable to parse semver for %s: %v", version, err) 1600 continue 1601 } 1602 1603 if parsed.GT(latest) { 1604 latest = parsed 1605 } 1606 } 1607 } 1608 1609 if latest.EQ(semver.Version{}) { 1610 return "", errors.New("no latest release branch found") 1611 } 1612 1613 return semverToReleaseBranch(latest), nil 1614 } 1615 1616 func semverToReleaseBranch(v semver.Version) string { 1617 return fmt.Sprintf("%s%d.%d", releaseBranchPrefix, v.Major, v.Minor) 1618 }