github.com/zppinho/prow@v0.0.0-20240510014325-1738badeb017/pkg/git/v2/interactor.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 "os" 25 "strings" 26 "time" 27 28 "github.com/sirupsen/logrus" 29 ) 30 31 // Interactor knows how to operate on a git repository cloned from GitHub 32 // using a local cache. 33 type Interactor interface { 34 // Directory exposes the directory in which the repository has been cloned 35 Directory() string 36 // Clean removes the repository. It is up to the user to call this once they are done 37 Clean() error 38 // ResetHard runs `git reset --hard` 39 ResetHard(commitlike string) error 40 // IsDirty checks whether the repo is dirty or not 41 IsDirty() (bool, error) 42 // Checkout runs `git checkout` 43 Checkout(commitlike string) error 44 // RevParse runs `git rev-parse` 45 RevParse(commitlike string) (string, error) 46 // RevParseN runs `git rev-parse`, but takes a slice of git revisions, and 47 // returns a map of the git revisions as keys and the SHAs as values. 48 RevParseN(rev []string) (map[string]string, error) 49 // BranchExists determines if a branch with the name exists 50 BranchExists(branch string) bool 51 // ObjectExists determines if the Git object exists locally 52 ObjectExists(sha string) (bool, error) 53 // CheckoutNewBranch creates a new branch from HEAD and checks it out 54 CheckoutNewBranch(branch string) error 55 // Merge merges the commitlike into the current HEAD 56 Merge(commitlike string) (bool, error) 57 // MergeWithStrategy merges the commitlike into the current HEAD with the strategy 58 MergeWithStrategy(commitlike, mergeStrategy string, opts ...MergeOpt) (bool, error) 59 // MergeAndCheckout merges all commitlikes into the current HEAD with the appropriate strategy 60 MergeAndCheckout(baseSHA string, mergeStrategy string, headSHAs ...string) error 61 // Am calls `git am` 62 Am(path string) error 63 // Fetch calls `git fetch arg...` 64 Fetch(arg ...string) error 65 // FetchRef fetches the refspec 66 FetchRef(refspec string) error 67 // FetchFromRemote fetches the branch of the given remote 68 FetchFromRemote(remote RemoteResolver, branch string) error 69 // CheckoutPullRequest fetches and checks out the synthetic refspec from GitHub for a pull request HEAD 70 CheckoutPullRequest(number int) error 71 // Config runs `git config` 72 Config(args ...string) error 73 // Diff runs `git diff` 74 Diff(head, sha string) (changes []string, err error) 75 // MergeCommitsExistBetween determines if merge commits exist between target and HEAD 76 MergeCommitsExistBetween(target, head string) (bool, error) 77 // ShowRef returns the commit for a commitlike. Unlike rev-parse it does not require a checkout. 78 ShowRef(commitlike string) (string, error) 79 } 80 81 // cacher knows how to cache and update repositories in a central cache 82 type cacher interface { 83 // MirrorClone sets up a mirror of the source repository. 84 MirrorClone() error 85 // RemoteUpdate fetches all updates from the remote. 86 RemoteUpdate() error 87 // FetchCommits fetches only the given commits. 88 FetchCommits([]string) error 89 // RetargetBranch moves the given branch to an already-existing commit. 90 RetargetBranch(string, string) error 91 } 92 93 // cloner knows how to clone repositories from a central cache 94 type cloner interface { 95 // Clone clones the repository from a local path. 96 Clone(from string) error 97 CloneWithRepoOpts(from string, repoOpts RepoOpts) error 98 } 99 100 // MergeOpt holds options for git merge operations. 101 // Currently only commit message option is supported. 102 type MergeOpt struct { 103 CommitMessage string 104 } 105 106 type interactor struct { 107 executor executor 108 remote RemoteResolver 109 dir string 110 logger *logrus.Entry 111 } 112 113 // Directory exposes the directory in which this repository has been cloned 114 func (i *interactor) Directory() string { 115 return i.dir 116 } 117 118 // Clean cleans up the repository from the on-disk cache 119 func (i *interactor) Clean() error { 120 return os.RemoveAll(i.dir) 121 } 122 123 // ResetHard runs `git reset --hard` 124 func (i *interactor) ResetHard(commitlike string) error { 125 // `git reset --hard` doesn't cleanup untracked file 126 i.logger.Info("Clean untracked files and dirs.") 127 if out, err := i.executor.Run("clean", "-df"); err != nil { 128 return fmt.Errorf("error clean -df: %v. output: %s", err, string(out)) 129 } 130 i.logger.WithField("commitlike", commitlike).Info("Reset hard.") 131 if out, err := i.executor.Run("reset", "--hard", commitlike); err != nil { 132 return fmt.Errorf("error reset hard %s: %v. output: %s", commitlike, err, string(out)) 133 } 134 return nil 135 } 136 137 // IsDirty checks whether the repo is dirty or not 138 func (i *interactor) IsDirty() (bool, error) { 139 i.logger.Info("Checking is dirty.") 140 b, err := i.executor.Run("status", "--porcelain") 141 if err != nil { 142 return false, fmt.Errorf("error add -A: %v. output: %s", err, string(b)) 143 } 144 return len(b) > 0, nil 145 } 146 147 // Clone clones the repository from a local path. 148 func (i *interactor) Clone(from string) error { 149 return i.CloneWithRepoOpts(from, RepoOpts{}) 150 } 151 152 // CloneWithRepoOpts clones the repository from a local path, but additionally 153 // use any repository options (RepoOpts) to customize the clone behavior. 154 func (i *interactor) CloneWithRepoOpts(from string, repoOpts RepoOpts) error { 155 i.logger.Infof("Creating a clone of the repo at %s from %s", i.dir, from) 156 cloneArgs := []string{"clone"} 157 158 if repoOpts.ShareObjectsWithPrimaryClone { 159 cloneArgs = append(cloneArgs, "--shared") 160 } 161 162 // Handle sparse checkouts. 163 if repoOpts.SparseCheckoutDirs != nil { 164 cloneArgs = append(cloneArgs, "--sparse") 165 } 166 167 cloneArgs = append(cloneArgs, from, i.dir) 168 169 if out, err := i.executor.Run(cloneArgs...); err != nil { 170 return fmt.Errorf("error creating a clone: %w %v", err, string(out)) 171 } 172 173 // For sparse checkouts, we have to do some additional housekeeping after 174 // the clone is completed. We use Git's global "-C <directory>" flag to 175 // switch to that directory before running the "sparse-checkout" command, 176 // because otherwise the command will fail (because it will try to run the 177 // command in the $PWD, which is not the same as the just-created clone 178 // directory (i.dir)). 179 if repoOpts.SparseCheckoutDirs != nil { 180 if len(repoOpts.SparseCheckoutDirs) == 0 { 181 return nil 182 } 183 sparseCheckoutArgs := []string{"-C", i.dir, "sparse-checkout", "set"} 184 sparseCheckoutArgs = append(sparseCheckoutArgs, repoOpts.SparseCheckoutDirs...) 185 186 timeBeforeSparseCheckout := time.Now() 187 if out, err := i.executor.Run(sparseCheckoutArgs...); err != nil { 188 return fmt.Errorf("error setting it to a sparse checkout: %w %v", err, string(out)) 189 } 190 gitMetrics.sparseCheckoutDuration.Observe(time.Since(timeBeforeSparseCheckout).Seconds()) 191 } 192 return nil 193 } 194 195 // MirrorClone sets up a mirror of the source repository. 196 func (i *interactor) MirrorClone() error { 197 i.logger.Infof("Creating a mirror of the repo at %s", i.dir) 198 remote, err := i.remote() 199 if err != nil { 200 return fmt.Errorf("could not resolve remote for cloning: %w", err) 201 } 202 if out, err := i.executor.Run("clone", "--mirror", remote, i.dir); err != nil { 203 return fmt.Errorf("error creating a mirror clone: %w %v", err, string(out)) 204 } 205 return nil 206 } 207 208 // Checkout runs git checkout. 209 func (i *interactor) Checkout(commitlike string) error { 210 i.logger.Infof("Checking out %q", commitlike) 211 if out, err := i.executor.Run("checkout", commitlike); err != nil { 212 return fmt.Errorf("error checking out %q: %w %v", commitlike, err, string(out)) 213 } 214 return nil 215 } 216 217 // RevParse runs git rev-parse. 218 func (i *interactor) RevParse(commitlike string) (string, error) { 219 i.logger.Infof("Parsing revision %q", commitlike) 220 out, err := i.executor.Run("rev-parse", commitlike) 221 if err != nil { 222 return "", fmt.Errorf("error parsing %q: %w %v", commitlike, err, string(out)) 223 } 224 return string(out), nil 225 } 226 227 func (i *interactor) RevParseN(revs []string) (map[string]string, error) { 228 if len(revs) == 0 { 229 return nil, errors.New("input revs must have at least 1 element") 230 } 231 232 i.logger.Infof("Parsing revisions %q", revs) 233 234 arg := append([]string{"rev-parse"}, revs...) 235 236 out, err := i.executor.Run(arg...) 237 if err != nil { 238 return nil, fmt.Errorf("error parsing %q: %w %v", revs, err, string(out)) 239 } 240 241 ret := make(map[string]string) 242 got := strings.Split(string(out), "\n") 243 244 // We expect the length to be at least 2. This is because if we have the 245 // minimal number of elements (just 1), "got" should look like ["abcdef...", 246 // "\n"] because the trailing newline should be its own element. 247 if len(got) < 2 { 248 return nil, fmt.Errorf("expected parsed output to be at least 2 elements, got %d", len(got)) 249 } 250 got = got[:len(got)-1] // Drop last element "\n". 251 252 for i, sha := range got { 253 ret[revs[i]] = sha 254 } 255 256 return ret, nil 257 } 258 259 // BranchExists returns true if branch exists in heads. 260 func (i *interactor) BranchExists(branch string) bool { 261 i.logger.Infof("Checking if branch %q exists", branch) 262 _, err := i.executor.Run("ls-remote", "--exit-code", "--heads", "origin", branch) 263 return err == nil 264 } 265 266 func (i *interactor) ObjectExists(sha string) (bool, error) { 267 i.logger.WithField("SHA", sha).Info("Checking if Git object exists") 268 output, err := i.executor.Run("cat-file", "-e", sha) 269 // If the object does not exist, cat-file will exit with a non-zero exit 270 // code. This will make err non-nil. However this is a known behavior, so 271 // we just log it. 272 // 273 // We still have the error type as a return value because the v1 git client 274 // adapter needs to know that this operation is not supported there. 275 if err != nil { 276 i.logger.WithError(err).WithField("SHA", sha).Debugf("error from 'git cat-file -e': %s", string(output)) 277 return false, nil 278 } 279 return true, nil 280 } 281 282 // CheckoutNewBranch creates a new branch and checks it out. 283 func (i *interactor) CheckoutNewBranch(branch string) error { 284 i.logger.Infof("Checking out new branch %q", branch) 285 if out, err := i.executor.Run("checkout", "-b", branch); err != nil { 286 return fmt.Errorf("error checking out new branch %q: %w %v", branch, err, string(out)) 287 } 288 return nil 289 } 290 291 // Merge attempts to merge commitlike into the current branch. It returns true 292 // if the merge completes. It returns an error if the abort fails. 293 func (i *interactor) Merge(commitlike string) (bool, error) { 294 return i.MergeWithStrategy(commitlike, "merge") 295 } 296 297 // MergeWithStrategy attempts to merge commitlike into the current branch given the merge strategy. 298 // It returns true if the merge completes. if the merge does not complete successfully, we try to 299 // abort it and return an error if the abort fails. 300 func (i *interactor) MergeWithStrategy(commitlike, mergeStrategy string, opts ...MergeOpt) (bool, error) { 301 i.logger.Infof("Merging %q using the %q strategy", commitlike, mergeStrategy) 302 switch mergeStrategy { 303 case "merge": 304 return i.mergeMerge(commitlike, opts...) 305 case "squash": 306 return i.squashMerge(commitlike) 307 case "rebase": 308 return i.mergeRebase(commitlike) 309 case "ifNecessary": 310 return i.mergeIfNecessary(commitlike, opts...) 311 default: 312 return false, fmt.Errorf("merge strategy %q is not supported", mergeStrategy) 313 } 314 } 315 316 func (i *interactor) mergeHelper(args []string, commitlike string, opts ...MergeOpt) (bool, error) { 317 if len(opts) == 0 { 318 args = append(args, []string{"-m", "merge"}...) 319 } else { 320 for _, opt := range opts { 321 args = append(args, []string{"-m", opt.CommitMessage}...) 322 } 323 } 324 325 args = append(args, commitlike) 326 327 out, err := i.executor.Run(args...) 328 if err == nil { 329 return true, nil 330 } 331 i.logger.WithError(err).Infof("Error merging %q: %s", commitlike, string(out)) 332 if out, err := i.executor.Run("merge", "--abort"); err != nil { 333 return false, fmt.Errorf("error aborting merge of %q: %w %v", commitlike, err, string(out)) 334 } 335 return false, nil 336 } 337 338 func (i *interactor) mergeMerge(commitlike string, opts ...MergeOpt) (bool, error) { 339 args := []string{"merge", "--no-ff", "--no-stat"} 340 return i.mergeHelper(args, commitlike, opts...) 341 } 342 343 func (i *interactor) mergeIfNecessary(commitlike string, opts ...MergeOpt) (bool, error) { 344 args := []string{"merge", "--ff", "--no-stat"} 345 return i.mergeHelper(args, commitlike, opts...) 346 } 347 348 func (i *interactor) squashMerge(commitlike string) (bool, error) { 349 out, err := i.executor.Run("merge", "--squash", "--no-stat", commitlike) 350 if err != nil { 351 i.logger.WithError(err).Warnf("Error staging merge for %q: %s", commitlike, string(out)) 352 if out, err := i.executor.Run("reset", "--hard", "HEAD"); err != nil { 353 return false, fmt.Errorf("error aborting merge of %q: %w %v", commitlike, err, string(out)) 354 } 355 return false, nil 356 } 357 out, err = i.executor.Run("commit", "--no-stat", "-m", "merge") 358 if err != nil { 359 i.logger.WithError(err).Warnf("Error committing merge for %q: %s", commitlike, string(out)) 360 if out, err := i.executor.Run("reset", "--hard", "HEAD"); err != nil { 361 return false, fmt.Errorf("error aborting merge of %q: %w %v", commitlike, err, string(out)) 362 } 363 return false, nil 364 } 365 return true, nil 366 } 367 368 func (i *interactor) mergeRebase(commitlike string) (bool, error) { 369 if commitlike == "" { 370 return false, errors.New("branch must be set") 371 } 372 373 headRev, err := i.revParse("HEAD") 374 if err != nil { 375 i.logger.WithError(err).Infof("Failed to parse HEAD revision") 376 return false, err 377 } 378 headRev = strings.TrimSuffix(headRev, "\n") 379 380 b, err := i.executor.Run("rebase", "--no-stat", headRev, commitlike) 381 if err != nil { 382 i.logger.WithField("out", string(b)).WithError(err).Infof("Rebase failed.") 383 if b, err := i.executor.Run("rebase", "--abort"); err != nil { 384 return false, fmt.Errorf("error aborting after failed rebase for commitlike %s: %v. output: %s", commitlike, err, string(b)) 385 } 386 return false, nil 387 } 388 return true, nil 389 } 390 391 func (i *interactor) revParse(args ...string) (string, error) { 392 fullArgs := append([]string{"rev-parse"}, args...) 393 b, err := i.executor.Run(fullArgs...) 394 if err != nil { 395 return "", errors.New(string(b)) 396 } 397 return string(b), nil 398 } 399 400 // Only the `merge` and `squash` strategies are supported. 401 func (i *interactor) MergeAndCheckout(baseSHA string, mergeStrategy string, headSHAs ...string) error { 402 if baseSHA == "" { 403 return errors.New("baseSHA must be set") 404 } 405 if err := i.Checkout(baseSHA); err != nil { 406 return err 407 } 408 for _, headSHA := range headSHAs { 409 ok, err := i.MergeWithStrategy(headSHA, mergeStrategy) 410 if err != nil { 411 return err 412 } else if !ok { 413 return fmt.Errorf("failed to merge %q", headSHA) 414 } 415 } 416 return nil 417 } 418 419 // Am tries to apply the patch in the given path into the current branch 420 // by performing a three-way merge (similar to git cherry-pick). It returns 421 // an error if the patch cannot be applied. 422 func (i *interactor) Am(path string) error { 423 i.logger.Infof("Applying patch at %s", path) 424 out, err := i.executor.Run("am", "--3way", path) 425 if err == nil { 426 return nil 427 } 428 i.logger.WithError(err).Infof("Patch apply failed with output: %s", string(out)) 429 if abortOut, abortErr := i.executor.Run("am", "--abort"); abortErr != nil { 430 i.logger.WithError(abortErr).Warningf("Aborting patch apply failed with output: %s", string(abortOut)) 431 } 432 return errors.New(string(bytes.TrimPrefix(out, []byte("The copy of the patch that failed is found in: .git/rebase-apply/patch")))) 433 } 434 435 // FetchCommits only fetches those commits which we want, and only if they are 436 // missing. 437 func (i *interactor) FetchCommits(commitSHAs []string) error { 438 fetchArgs := []string{"--no-write-fetch-head", "--no-tags"} 439 440 // For each commit SHA, check if it already exists. If so, don't bother 441 // fetching it. 442 var missingCommits bool 443 for _, commitSHA := range commitSHAs { 444 if exists, _ := i.ObjectExists(commitSHA); exists { 445 continue 446 } 447 448 fetchArgs = append(fetchArgs, commitSHA) 449 missingCommits = true 450 } 451 452 // Skip the fetch operation altogether if nothing is missing (we already 453 // fetched everything previously at some point). 454 if !missingCommits { 455 return nil 456 } 457 458 if err := i.Fetch(fetchArgs...); err != nil { 459 return fmt.Errorf("failed to fetch %s: %v", fetchArgs, err) 460 } 461 462 return nil 463 } 464 465 // RetargetBranch moves the given branch to an already-existing commit. 466 func (i *interactor) RetargetBranch(branch, sha string) error { 467 args := []string{"branch", "-f", branch, sha} 468 if out, err := i.executor.Run(args...); err != nil { 469 return fmt.Errorf("error retargeting branch: %w %v", err, string(out)) 470 } 471 472 return nil 473 } 474 475 // RemoteUpdate fetches all updates from the remote. 476 func (i *interactor) RemoteUpdate() error { 477 // We might need to refresh the token for accessing remotes in case of GitHub App auth (ghs tokens are only valid for 478 // 1 hour, see https://github.com/kubernetes/test-infra/issues/31182). 479 // Therefore, we resolve the remote again and update the clone's remote URL with a fresh token. 480 remote, err := i.remote() 481 if err != nil { 482 return fmt.Errorf("could not resolve remote for updating: %w", err) 483 } 484 485 i.logger.Info("Setting remote URL") 486 if out, err := i.executor.Run("remote", "set-url", "origin", remote); err != nil { 487 return fmt.Errorf("error setting remote URL: %w %v", err, string(out)) 488 } 489 490 i.logger.Info("Updating from remote") 491 if out, err := i.executor.Run("remote", "update", "--prune"); err != nil { 492 return fmt.Errorf("error updating: %w %v", err, string(out)) 493 } 494 return nil 495 } 496 497 // Fetch fetches all updates from the remote. 498 func (i *interactor) Fetch(arg ...string) error { 499 remote, err := i.remote() 500 if err != nil { 501 return fmt.Errorf("could not resolve remote for fetching: %w", err) 502 } 503 arg = append([]string{"fetch", remote}, arg...) 504 i.logger.Infof("Fetching from %s", remote) 505 if out, err := i.executor.Run(arg...); err != nil { 506 return fmt.Errorf("error fetching: %w %v", err, string(out)) 507 } 508 return nil 509 } 510 511 // FetchRef fetches a refspec from the remote and leaves it as FETCH_HEAD. 512 func (i *interactor) FetchRef(refspec string) error { 513 remote, err := i.remote() 514 if err != nil { 515 return fmt.Errorf("could not resolve remote for fetching: %w", err) 516 } 517 i.logger.Infof("Fetching %q from %s", refspec, remote) 518 if out, err := i.executor.Run("fetch", remote, refspec); err != nil { 519 return fmt.Errorf("error fetching %q: %w %v", refspec, err, string(out)) 520 } 521 return nil 522 } 523 524 // FetchFromRemote fetches all update from a specific remote and branch and leaves it as FETCH_HEAD. 525 func (i *interactor) FetchFromRemote(remote RemoteResolver, branch string) error { 526 r, err := remote() 527 if err != nil { 528 return fmt.Errorf("couldn't get remote: %w", err) 529 } 530 531 i.logger.Infof("Fetching %s from %s", branch, r) 532 if out, err := i.executor.Run("fetch", r, branch); err != nil { 533 return fmt.Errorf("error fetching %s from %s: %w %v", branch, r, err, string(out)) 534 } 535 return nil 536 } 537 538 // CheckoutPullRequest fetches the HEAD of a pull request using a synthetic refspec 539 // available on GitHub remotes and creates a branch at that commit. 540 func (i *interactor) CheckoutPullRequest(number int) error { 541 i.logger.Infof("Checking out pull request %d", number) 542 if err := i.FetchRef(fmt.Sprintf("pull/%d/head", number)); err != nil { 543 return err 544 } 545 if err := i.Checkout("FETCH_HEAD"); err != nil { 546 return err 547 } 548 if err := i.CheckoutNewBranch(fmt.Sprintf("pull%d", number)); err != nil { 549 return err 550 } 551 return nil 552 } 553 554 // Config runs git config. 555 func (i *interactor) Config(args ...string) error { 556 i.logger.WithField("args", args).Info("Configuring.") 557 if out, err := i.executor.Run(append([]string{"config"}, args...)...); err != nil { 558 return fmt.Errorf("error configuring %v: %w %v", args, err, string(out)) 559 } 560 return nil 561 } 562 563 // Diff lists the difference between the two references, returning the output 564 // line by line. 565 func (i *interactor) Diff(head, sha string) ([]string, error) { 566 i.logger.Infof("Finding the differences between %q and %q", head, sha) 567 out, err := i.executor.Run("diff", head, sha, "--name-only") 568 if err != nil { 569 return nil, err 570 } 571 var changes []string 572 scan := bufio.NewScanner(bytes.NewReader(out)) 573 scan.Split(bufio.ScanLines) 574 for scan.Scan() { 575 changes = append(changes, scan.Text()) 576 } 577 return changes, nil 578 } 579 580 // MergeCommitsExistBetween runs 'git log <target>..<head> --merged' to verify 581 // if merge commits exist between "target" and "head". 582 func (i *interactor) MergeCommitsExistBetween(target, head string) (bool, error) { 583 i.logger.Infof("Determining if merge commits exist between %q and %q", target, head) 584 out, err := i.executor.Run("log", fmt.Sprintf("%s..%s", target, head), "--oneline", "--merges") 585 if err != nil { 586 return false, fmt.Errorf("error verifying if merge commits exist between %q and %q: %v %s", target, head, err, string(out)) 587 } 588 return len(out) != 0, nil 589 } 590 591 func (i *interactor) ShowRef(commitlike string) (string, error) { 592 i.logger.Infof("Getting the commit sha for commitlike %s", commitlike) 593 out, err := i.executor.Run("show-ref", "-s", commitlike) 594 if err != nil { 595 return "", fmt.Errorf("failed to get commit sha for commitlike %s: %w", commitlike, err) 596 } 597 return strings.TrimSpace(string(out)), nil 598 }