go.fuchsia.dev/jiri@v0.0.0-20240502161911-b66513b29486/cmd/jiri/patch.go (about)

     1  // Copyright 2016 The Fuchsia Authors. All rights reserved.
     2  // Use of this source code is governed by a BSD-style
     3  // license that can be found in the LICENSE file.
     4  
     5  package main
     6  
     7  import (
     8  	"fmt"
     9  	"net/url"
    10  	"os"
    11  	"strconv"
    12  	"strings"
    13  	"sync/atomic"
    14  
    15  	"go.fuchsia.dev/jiri"
    16  	"go.fuchsia.dev/jiri/cmdline"
    17  	"go.fuchsia.dev/jiri/gerrit"
    18  	"go.fuchsia.dev/jiri/gitutil"
    19  	"go.fuchsia.dev/jiri/project"
    20  )
    21  
    22  var (
    23  	patchRebaseFlag     bool
    24  	patchRebaseRevision string
    25  	patchRebaseBranch   string
    26  	patchTopicFlag      bool
    27  	patchBranchFlag     string
    28  	patchDeleteFlag     bool
    29  	patchHostFlag       string
    30  	patchForceFlag      bool
    31  	cherryPickFlag      bool
    32  	detachedHeadFlag    bool
    33  	patchProjectFlag    string
    34  	rebaseFailures      uint32
    35  )
    36  
    37  func init() {
    38  	cmdPatch.Flags.StringVar(&patchBranchFlag, "branch", "", "Name of the branch the patch will be applied to")
    39  	cmdPatch.Flags.BoolVar(&patchDeleteFlag, "delete", false, "Delete the existing branch if already exists")
    40  	cmdPatch.Flags.BoolVar(&patchForceFlag, "force", false, "Use force when deleting the existing branch")
    41  	cmdPatch.Flags.BoolVar(&patchRebaseFlag, "rebase", false, "Rebase the change after downloading")
    42  	cmdPatch.Flags.StringVar(&patchRebaseRevision, "rebase-revision", "", "Rebase the change to a specific revision after downloading")
    43  	cmdPatch.Flags.StringVar(&patchRebaseBranch, "rebase-branch", "", "The branch to rebase the change onto")
    44  	cmdPatch.Flags.StringVar(&patchHostFlag, "host", "", `Gerrit host to use. Defaults to gerrit host specified in manifest.`)
    45  	cmdPatch.Flags.StringVar(&patchProjectFlag, "project", "", `Project to apply patch to. This cannot be passed with topic flag.`)
    46  	cmdPatch.Flags.BoolVar(&patchTopicFlag, "topic", false, `Patch whole topic.`)
    47  	cmdPatch.Flags.BoolVar(&cherryPickFlag, "cherry-pick", false, `Cherry-pick patches instead of checking out.`)
    48  	cmdPatch.Flags.BoolVar(&detachedHeadFlag, "no-branch", false, `Don't create the branch for the patch.`)
    49  }
    50  
    51  // Use special address codes for errors that are addressable by the user. The
    52  // recipes will use this to detect when the failure should be considered an
    53  // infrastructure failure vs a failure that is addressable by the user.
    54  const noSuchProjectErr = cmdline.ErrExitCode(23)
    55  const rebaseFailedErr = cmdline.ErrExitCode(24)
    56  
    57  // cmdPatch represents the "jiri patch" command.
    58  var cmdPatch = &cmdline.Command{
    59  	Runner: jiri.RunnerFunc(runPatch),
    60  	Name:   "patch",
    61  	Short:  "Patch in the existing change",
    62  	Long: `
    63  Command "patch" applies the existing changelist to the current project. The
    64  change can be identified either using change ID, in which case the latest
    65  patchset will be used, or the the full reference. By default patch will be
    66  checked-out on a new branch.
    67  
    68  A new branch will be created to apply the patch to. The default name of this
    69  branch is "change/<changeset>/<patchset>", but this can be overridden using
    70  the -branch flag. The command will fail if the branch already exists. The
    71  -delete flag will delete the branch if already exists. Use the -force flag to
    72  force deleting the branch even if it contains unmerged changes).
    73  
    74  if -topic flag is true jiri will fetch whole topic and will try to apply to
    75  individual projects. Patch will assume topic is of form {USER}-{BRANCH} and
    76  will try to create branch name out of it. If this fails default branch name
    77  will be same as topic. Currently patch does not support the scenario when
    78  change "B" is created on top of "A" and both have same topic.
    79  `,
    80  	ArgsName: "<change or topic>",
    81  	ArgsLong: "<change or topic> is a change ID, full reference or topic when -topic is true.",
    82  }
    83  
    84  // patchProject checks out the given change.
    85  func patchProject(jirix *jiri.X, local project.Project, ref, branch, remote string) (bool, error) {
    86  	scm := gitutil.New(jirix, gitutil.RootDirOpt(local.Path))
    87  	if !detachedHeadFlag {
    88  		if branch == "" {
    89  			cl, ps, err := gerrit.ParseRefString(ref)
    90  			if err != nil {
    91  				return false, err
    92  			}
    93  			branch = fmt.Sprintf("change/%v/%v", cl, ps)
    94  		}
    95  		jirix.Logger.Infof("Patching project %s(%s) on branch %q to ref %q\n", local.Name, local.Path, branch, ref)
    96  		branchExists, err := scm.BranchExists(branch)
    97  		if err != nil {
    98  			return false, err
    99  		}
   100  		if branchExists {
   101  			if patchDeleteFlag {
   102  				_, currentBranch, err := scm.GetBranches()
   103  				if err != nil {
   104  					return false, err
   105  				}
   106  				if currentBranch == branch {
   107  					if err := scm.CheckoutBranch("remotes/origin/"+remote, (local.GitSubmodules && jirix.EnableSubmodules), false, gitutil.DetachOpt(true)); err != nil {
   108  						return false, err
   109  					}
   110  				}
   111  				if err := scm.DeleteBranch(branch, gitutil.ForceOpt(patchForceFlag)); err != nil {
   112  					jirix.Logger.Errorf("Cannot delete branch %q: %s", branch, err)
   113  					jirix.IncrementFailures()
   114  					return false, nil
   115  				}
   116  			} else {
   117  				jirix.Logger.Errorf("Branch %q already exists in project %q", branch, local.Name)
   118  				jirix.IncrementFailures()
   119  				return false, nil
   120  			}
   121  		}
   122  	} else {
   123  		jirix.Logger.Infof("Patching project %s(%s) to ref %q\n", local.Name, local.Path, ref)
   124  	}
   125  	if err := scm.FetchRefspec("origin", ref, jirix.EnableSubmodules); err != nil {
   126  		return false, err
   127  	}
   128  	branchBase := "FETCH_HEAD"
   129  	lastRef := ""
   130  	if cherryPickFlag {
   131  		if state, err := project.GetProjectState(jirix, local, false); err != nil {
   132  			return false, err
   133  		} else {
   134  			lastRef = state.CurrentBranch.Name
   135  			if lastRef == "" {
   136  				lastRef = state.CurrentBranch.Revision
   137  			}
   138  		}
   139  		branchBase = "HEAD"
   140  	}
   141  	if !detachedHeadFlag {
   142  		if err := scm.CreateBranchFromRef(branch, branchBase); err != nil {
   143  			return false, err
   144  		}
   145  		if err := scm.SetUpstream(branch, "origin/"+remote); err != nil {
   146  			return false, fmt.Errorf("setting upstream to 'origin/%s': %s", remote, err)
   147  		}
   148  		branchBase = branch
   149  	}
   150  
   151  	// Perform rebases prior to checking out the new branch to avoid unnecesary
   152  	// file writes.
   153  	if patchRebaseFlag {
   154  		if patchRebaseRevision != "" {
   155  			if err := rebaseProjectWRevision(jirix, local, branchBase, patchRebaseRevision); err != nil {
   156  				return false, err
   157  			}
   158  		} else {
   159  			if err := rebaseProject(jirix, local, branchBase, remote); err != nil {
   160  				return false, err
   161  			}
   162  		}
   163  
   164  		// The cherry pick stanza below relies on the ref being present at
   165  		// FETCH_HEAD. This will not be true after a rebase, as the rebase
   166  		// functions perform fetches of their own.
   167  		if cherryPickFlag {
   168  			if err := scm.FetchRefspec("origin", ref, jirix.EnableSubmodules); err != nil {
   169  				return false, err
   170  			}
   171  		}
   172  	}
   173  
   174  	if err := scm.CheckoutBranch(branchBase, (local.GitSubmodules && jirix.EnableSubmodules), false); err != nil {
   175  		return false, err
   176  	}
   177  	if cherryPickFlag {
   178  		if err := scm.CherryPick("FETCH_HEAD"); err != nil {
   179  			jirix.Logger.Errorf("Error: %s\n", err)
   180  			jirix.IncrementFailures()
   181  
   182  			jirix.Logger.Infof("Aborting and checking out last ref: %s\n", lastRef)
   183  
   184  			// abort cherry-pick
   185  			if err := scm.CherryPickAbort(); err != nil {
   186  				jirix.Logger.Errorf("Cherry-pick abort failed. Error:%s\nPlease do it manually:'%s'\n\n", err,
   187  					jirix.Color.Yellow("git -C %q cherry-pick --abort && git -C %q checkout %s", local.Path, local.Path, lastRef))
   188  				return false, nil
   189  			}
   190  
   191  			// checkout last ref
   192  			if err := scm.CheckoutBranch(lastRef, (local.GitSubmodules && jirix.EnableSubmodules), false); err != nil {
   193  				jirix.Logger.Errorf("Not able to checkout last ref. Error:%s\nPlease do it manually:'%s'\n\n", err,
   194  					jirix.Color.Yellow("git -C %q checkout %s", local.Path, lastRef))
   195  				return false, nil
   196  			}
   197  
   198  			scm.DeleteBranch(branch, gitutil.ForceOpt(true))
   199  
   200  			return false, nil
   201  		}
   202  	}
   203  	jirix.Logger.Infof("Project patched\n")
   204  	return true, nil
   205  }
   206  
   207  // rebaseProject rebases one branch of a project on top of a remote branch.
   208  func rebaseProject(jirix *jiri.X, project project.Project, branch, remoteBranch string) error {
   209  	jirix.Logger.Infof("Rebasing branch %s in project %s(%s)\n", branch, project.Name, project.Path)
   210  	scm := gitutil.New(jirix, gitutil.RootDirOpt(project.Path))
   211  	name, email, err := scm.UserInfoForCommit("HEAD")
   212  	if err != nil {
   213  		return fmt.Errorf("Rebase: cannot get user info for HEAD: %s", err)
   214  	}
   215  	// TODO: provide a way to set username and email
   216  	scm = gitutil.New(jirix, gitutil.UserNameOpt(name), gitutil.UserEmailOpt(email), gitutil.RootDirOpt(project.Path))
   217  	if err := scm.FetchRefspec("origin", remoteBranch, jirix.EnableSubmodules); err != nil {
   218  		jirix.Logger.Errorf("Not able to fetch branch %q: %s", remoteBranch, err)
   219  		jirix.IncrementFailures()
   220  		return nil
   221  	}
   222  	if err := scm.RebaseBranch(branch, "remotes/origin/"+remoteBranch, gitutil.RebaseMerges(true)); err != nil {
   223  		if err2 := scm.RebaseAbort(); err2 != nil {
   224  			return err2
   225  		}
   226  		jirix.Logger.Errorf("Cannot rebase the change: %s", err)
   227  		jirix.IncrementFailures()
   228  		atomic.AddUint32(&rebaseFailures, 1)
   229  		return nil
   230  	}
   231  	jirix.Logger.Infof("Project rebased\n")
   232  	return nil
   233  }
   234  
   235  // rebaseProjectWRevision rebases one branch of a project on top of a revision.
   236  func rebaseProjectWRevision(jirix *jiri.X, project project.Project, branch, revision string) error {
   237  	jirix.Logger.Infof("Rebasing branch %s in project %s(%s)\n", branch, project.Name, project.Path)
   238  	scm := gitutil.New(jirix, gitutil.RootDirOpt(project.Path))
   239  	name, email, err := scm.UserInfoForCommit("HEAD")
   240  	if err != nil {
   241  		return fmt.Errorf("Rebase: cannot get user info for HEAD: %s", err)
   242  	}
   243  	scm = gitutil.New(jirix, gitutil.UserNameOpt(name), gitutil.UserEmailOpt(email), gitutil.RootDirOpt(project.Path))
   244  	if err := scm.Fetch("origin", jirix.EnableSubmodules, gitutil.PruneOpt(true)); err != nil {
   245  		jirix.Logger.Errorf("Not able to fetch origin: %v", err)
   246  		jirix.IncrementFailures()
   247  		return nil
   248  	}
   249  	if err := scm.FetchRefspec("origin", revision, jirix.EnableSubmodules); err != nil {
   250  		jirix.Logger.Errorf("Not able to fetch revision %q: %s", revision, err)
   251  		jirix.IncrementFailures()
   252  		return nil
   253  	}
   254  	if err := scm.RebaseBranch(branch, revision, gitutil.RebaseMerges(true)); err != nil {
   255  		if err2 := scm.RebaseAbort(); err2 != nil {
   256  			return err2
   257  		}
   258  		jirix.Logger.Errorf("Cannot rebase the change: %s", err)
   259  		jirix.IncrementFailures()
   260  		atomic.AddUint32(&rebaseFailures, 1)
   261  		return nil
   262  	}
   263  	jirix.Logger.Infof("Project rebased\n")
   264  	return nil
   265  }
   266  
   267  func findProject(jirix *jiri.X, projectName string, projects project.Projects, host string, hostUrl *url.URL, ref string) *project.Project {
   268  	var projectToPatch *project.Project
   269  	var projectToPatchNoGerritHost *project.Project
   270  	for _, p := range projects {
   271  		if p.Name == projectName {
   272  			if host != "" && p.GerritHost != host {
   273  				if p.GerritHost == "" {
   274  					cp := p
   275  					projectToPatchNoGerritHost = &cp
   276  					//skip for now
   277  					continue
   278  				} else {
   279  					u, err := url.Parse(p.GerritHost)
   280  					if err != nil {
   281  						jirix.Logger.Warningf("invalid Gerrit host %q for project %s: %s", p.GerritHost, p.Name, err)
   282  					}
   283  					if u.Host != hostUrl.Host {
   284  						jirix.Logger.Debugf("skipping project %s(%s) for CL %s\n\n", p.Name, p.Path, ref)
   285  						continue
   286  					}
   287  				}
   288  			}
   289  			projectToPatch = &p
   290  			break
   291  		}
   292  	}
   293  	if projectToPatch == nil && projectToPatchNoGerritHost != nil {
   294  		// Try to patch the project with no gerrit host
   295  		projectToPatch = projectToPatchNoGerritHost
   296  	}
   297  	return projectToPatch
   298  }
   299  
   300  func runPatch(jirix *jiri.X, args []string) error {
   301  	if expected, got := 1, len(args); expected != got {
   302  		return jirix.UsageErrorf("unexpected number of arguments: expected %v, got %v", expected, got)
   303  	}
   304  	arg := args[0]
   305  
   306  	if patchProjectFlag != "" && patchTopicFlag {
   307  		return jirix.UsageErrorf("-topic and -project flags cannot be used together")
   308  	}
   309  
   310  	if patchRebaseRevision != "" && (!patchRebaseFlag || patchProjectFlag == "") {
   311  		return jirix.UsageErrorf("-rebase-revision should only be used with -rebase and -project flag")
   312  	}
   313  
   314  	var cl int
   315  	var ps int
   316  	var err error
   317  	changeRef := ""
   318  	remoteBranch := ""
   319  	if !patchTopicFlag {
   320  		cl, ps, err = gerrit.ParseRefString(arg)
   321  		if err != nil {
   322  			if patchProjectFlag != "" {
   323  				return fmt.Errorf("Please pass change ref with -project flag (refs/changes/<ps>/<cl>/<patch-set>)")
   324  			}
   325  			cl, err = strconv.Atoi(arg)
   326  			if err != nil {
   327  				return fmt.Errorf("invalid argument: %v", arg)
   328  			}
   329  		} else {
   330  			changeRef = arg
   331  		}
   332  	}
   333  
   334  	var p *project.Project
   335  	host := patchHostFlag
   336  	if patchProjectFlag != "" {
   337  		projects, err := project.LocalProjects(jirix, project.FastScan)
   338  		if err != nil {
   339  			return err
   340  		}
   341  		var hostUrl *url.URL
   342  		if host != "" {
   343  			hostUrl, err = url.Parse(host)
   344  			if err != nil {
   345  				return fmt.Errorf("invalid Gerrit host %q: %s", host, err)
   346  			}
   347  		}
   348  		p = findProject(jirix, patchProjectFlag, projects, host, hostUrl, changeRef)
   349  		if p == nil {
   350  			jirix.Logger.Errorf("Cannot find project for %q", patchProjectFlag)
   351  			return noSuchProjectErr
   352  		}
   353  		// TODO: TO-592 - remove this hardcode
   354  		if patchRebaseBranch == "" && p.RemoteBranch != "" {
   355  			remoteBranch = p.RemoteBranch
   356  		} else if patchRebaseBranch != "" {
   357  			remoteBranch = patchRebaseBranch
   358  		} else {
   359  			remoteBranch = "main"
   360  		}
   361  	} else if project, perr := currentProject(jirix); perr == nil {
   362  		p = &project
   363  		if host == "" {
   364  			if p.GerritHost == "" {
   365  				return fmt.Errorf("no Gerrit host; use the '--host' flag, or add a 'gerrithost' attribute for project %q", p.Name)
   366  			}
   367  			host = p.GerritHost
   368  		}
   369  	}
   370  	if !patchTopicFlag && p != nil {
   371  		if remoteBranch == "" || changeRef == "" {
   372  			hostUrl, err := url.Parse(host)
   373  			if err != nil {
   374  				return fmt.Errorf("invalid Gerrit host %q: %s", host, err)
   375  			}
   376  			g := gerrit.New(jirix, hostUrl)
   377  
   378  			change, err := g.GetChange(cl)
   379  			if err != nil {
   380  				return err
   381  			}
   382  			remoteBranch = change.Branch
   383  			changeRef = change.Reference()
   384  		}
   385  		branch := patchBranchFlag
   386  		if ps != -1 {
   387  			if _, err = patchProject(jirix, *p, arg, branch, remoteBranch); err != nil {
   388  				return err
   389  			}
   390  		} else {
   391  			if _, err = patchProject(jirix, *p, changeRef, branch, remoteBranch); err != nil {
   392  				return err
   393  			}
   394  		}
   395  	} else {
   396  		if host == "" {
   397  			return fmt.Errorf("no Gerrit host; use the '--host' flag or run this from inside a project")
   398  		}
   399  		hostUrl, err := url.Parse(host)
   400  		if err != nil {
   401  			return fmt.Errorf("invalid Gerrit host %q: %v", host, err)
   402  		}
   403  		g := gerrit.New(jirix, hostUrl)
   404  
   405  		var changes gerrit.CLList
   406  		branch := patchBranchFlag
   407  		if patchTopicFlag {
   408  			temp, err := g.ListOpenChangesByTopic(arg)
   409  			if err != nil {
   410  				return err
   411  			}
   412  			if len(temp) == 0 {
   413  				return fmt.Errorf("No changes found with topic %q", arg)
   414  			}
   415  
   416  			projectMap := make(map[string]map[string]gerrit.Change)
   417  			//Handle stacked changes
   418  			for _, change := range temp {
   419  				v, ok := projectMap[change.Project]
   420  				if !ok {
   421  					v = make(map[string]gerrit.Change)
   422  					projectMap[change.Project] = v
   423  				}
   424  				v[change.Change_id] = change
   425  			}
   426  
   427  			for p, topicChanges := range projectMap {
   428  				// only CL in the project
   429  				if len(topicChanges) == 1 {
   430  					for _, change := range topicChanges {
   431  						changes = append(changes, change)
   432  						break
   433  					}
   434  					continue
   435  				}
   436  
   437  				// stacked CLs, get the top one
   438  				if cherryPickFlag {
   439  					return fmt.Errorf("Multiple CLs for projects %q. We do not support this with cherry-pick flag", p)
   440  				}
   441  				var relatedChanges *gerrit.RelatedChanges
   442  				relatedChangesMap := make(map[string]struct{})
   443  
   444  				// get related changes and build map.
   445  				// loop will only run once as we just need one change to build the map.
   446  				for _, change := range topicChanges {
   447  					relatedChanges, err = g.GetRelatedChanges(change.Number, change.Current_revision)
   448  					if err != nil {
   449  						return err
   450  					}
   451  					changeAdded := false
   452  					// get the top one and also build a map
   453  					for _, relatedChange := range relatedChanges.Changes {
   454  						if !changeAdded {
   455  							if c, ok := topicChanges[relatedChange.Change_id]; ok {
   456  								changes = append(changes, c)
   457  								changeAdded = true
   458  							}
   459  						}
   460  						relatedChangesMap[relatedChange.Change_id] = struct{}{}
   461  					}
   462  					break
   463  				}
   464  				// check if all the CLs contained in topic are in related CL list
   465  				for changeId, change := range topicChanges {
   466  					if _, ok := relatedChangesMap[changeId]; !ok {
   467  						var cn []string
   468  						for _, c := range topicChanges {
   469  							cn = append(cn, strconv.Itoa(c.Number))
   470  						}
   471  						return fmt.Errorf("Not all of the changes (%s) for project %q and topic %q are related to each other", strings.Join(cn, ","), change.Project, arg)
   472  					}
   473  				}
   474  			}
   475  			ps = -1
   476  			if branch == "" {
   477  				userPrefix := os.Getenv("USER") + "-"
   478  				if strings.HasPrefix(arg, userPrefix) {
   479  					branch = strings.Replace(arg, userPrefix, "", 1)
   480  				} else {
   481  					branch = arg
   482  				}
   483  			}
   484  		} else {
   485  			change, err := g.GetChange(cl)
   486  			if err != nil {
   487  				return err
   488  			}
   489  			changes = append(changes, *change)
   490  		}
   491  		projects, err := project.LocalProjects(jirix, project.FastScan)
   492  		if err != nil {
   493  			return err
   494  		}
   495  		for _, change := range changes {
   496  			var ref string
   497  			if ps != -1 {
   498  				ref = arg
   499  			} else {
   500  				ref = change.Reference()
   501  			}
   502  			if projectToPatch := findProject(jirix, change.Project, projects, host, hostUrl, g.GetChangeURL(change.Number)); projectToPatch != nil {
   503  				if _, err := patchProject(jirix, *projectToPatch, ref, branch, change.Branch); err != nil {
   504  					return err
   505  				}
   506  				fmt.Println()
   507  			} else {
   508  				jirix.Logger.Errorf("Cannot find project to patch CL %s\n", g.GetChangeURL(change.Number))
   509  				jirix.IncrementFailures()
   510  				fmt.Println()
   511  			}
   512  		}
   513  	}
   514  	// In the case where jiri is called programatically by a recipe,
   515  	// we want to make it clear to the recipe if all failures were rebase errors.
   516  	if rebaseFailures != 0 && rebaseFailures == jirix.Failures() {
   517  		return rebaseFailedErr
   518  	} else if jirix.Failures() != 0 {
   519  		return fmt.Errorf("Patch failed")
   520  	}
   521  	return nil
   522  }