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

     1  // Copyright 2017 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  	"path/filepath"
    12  	"regexp"
    13  	"sort"
    14  	"strings"
    15  	"sync"
    16  
    17  	"go.fuchsia.dev/jiri"
    18  	"go.fuchsia.dev/jiri/cmdline"
    19  	"go.fuchsia.dev/jiri/gerrit"
    20  	"go.fuchsia.dev/jiri/gitutil"
    21  	"go.fuchsia.dev/jiri/project"
    22  )
    23  
    24  var branchFlags struct {
    25  	deleteFlag                bool
    26  	deleteMergedClsFlag       bool
    27  	deleteMergedFlag          bool
    28  	forceDeleteFlag           bool
    29  	listFlag                  bool
    30  	overrideProjectConfigFlag bool
    31  }
    32  
    33  type MultiError []error
    34  
    35  func (m MultiError) Error() string {
    36  	s := []string{}
    37  	for _, e := range m {
    38  		if e != nil {
    39  			s = append(s, e.Error())
    40  		}
    41  	}
    42  	return strings.Join(s, "\n")
    43  }
    44  
    45  func (m MultiError) String() string {
    46  	return m.Error()
    47  }
    48  
    49  var cmdBranch = &cmdline.Command{
    50  	Runner: jiri.RunnerFunc(runBranch),
    51  	Name:   "branch",
    52  	Short:  "Show or delete branches",
    53  	Long: `
    54  Show all the projects having branch <branch> .If -d or -D is passed, <branch>
    55  is deleted. if <branch> is not passed, show all projects which have branches other than "main"`,
    56  	ArgsName: "<branch>",
    57  	ArgsLong: "<branch> is the name branch",
    58  }
    59  
    60  func init() {
    61  	flags := &cmdBranch.Flags
    62  	flags.BoolVar(&branchFlags.deleteFlag, "d", false, "Delete branch from project. Similar to running 'git branch -d <branch-name>'")
    63  	flags.BoolVar(&branchFlags.forceDeleteFlag, "D", false, "Force delete branch from project. Similar to running 'git branch -D <branch-name>'")
    64  	flags.BoolVar(&branchFlags.listFlag, "list", false, "Show only projects with current branch <branch>")
    65  	flags.BoolVar(&branchFlags.overrideProjectConfigFlag, "override-pc", false, "Overrrides project config's ignore and noupdate flag and deletes the branch.")
    66  	flags.BoolVar(&branchFlags.deleteMergedFlag, "delete-merged", false, "Delete merged branches. Merged branches are the tracked branches merged with their tracking remote or un-tracked branches merged with the branch specified in manifest(default main). If <branch> is provided, it will only delete branch <branch> if merged.")
    67  	flags.BoolVar(&branchFlags.deleteMergedClsFlag, "delete-merged-cl", false, "Implies -delete-merged. It also parses commit messages for ChangeID and checks with gerrit if those changes have been merged and deletes those branches. It will ignore a branch if it differs with remote by more than 10 commits.")
    68  }
    69  
    70  func displayProjects(jirix *jiri.X, branch string) error {
    71  	localProjects, err := project.LocalProjects(jirix, project.FastScan)
    72  	if err != nil {
    73  		return err
    74  	}
    75  	jirix.TimerPush("Get states")
    76  	states, err := project.GetProjectStates(jirix, localProjects, false)
    77  	if err != nil {
    78  		return err
    79  	}
    80  
    81  	jirix.TimerPop()
    82  	cDir, err := os.Getwd()
    83  	if err != nil {
    84  		return err
    85  	}
    86  	var keys project.ProjectKeys
    87  	for key := range states {
    88  		keys = append(keys, key)
    89  	}
    90  	sort.Sort(keys)
    91  	for _, key := range keys {
    92  		state := states[key]
    93  		relativePath, err := filepath.Rel(cDir, state.Project.Path)
    94  		if err != nil {
    95  			return err
    96  		}
    97  		if branch == "" {
    98  			var branches []string
    99  			main := ""
   100  			for _, b := range state.Branches {
   101  				name := b.Name
   102  				if state.CurrentBranch.Name == b.Name {
   103  					name = "*" + jirix.Color.Green("%s", b.Name)
   104  				}
   105  				if b.Name != "main" {
   106  					branches = append(branches, name)
   107  				} else {
   108  					main = name
   109  				}
   110  			}
   111  			if len(branches) != 0 {
   112  				if main != "" {
   113  					branches = append(branches, main)
   114  				}
   115  				fmt.Printf("%s: %s(%s)\n", jirix.Color.Yellow("Project"), state.Project.Name, relativePath)
   116  				fmt.Printf("%s: %s\n\n", jirix.Color.Yellow("Branch(es)"), strings.Join(branches, ", "))
   117  			}
   118  
   119  		} else if branchFlags.listFlag {
   120  			if state.CurrentBranch.Name == branch {
   121  				fmt.Printf("%s(%s)\n", state.Project.Name, relativePath)
   122  			}
   123  		} else {
   124  			for _, b := range state.Branches {
   125  				if b.Name == branch {
   126  					fmt.Printf("%s(%s)\n", state.Project.Name, relativePath)
   127  					break
   128  				}
   129  			}
   130  		}
   131  	}
   132  	jirix.TimerPop()
   133  	return nil
   134  }
   135  
   136  func runBranch(jirix *jiri.X, args []string) error {
   137  	branch := ""
   138  	if len(args) > 1 {
   139  		return jirix.UsageErrorf("Please provide only one branch")
   140  	} else if len(args) == 1 {
   141  		branch = args[0]
   142  	}
   143  	if branchFlags.deleteFlag || branchFlags.forceDeleteFlag {
   144  		if branch == "" {
   145  			return jirix.UsageErrorf("Please provide branch to delete")
   146  		}
   147  		return deleteBranches(jirix, branch)
   148  	}
   149  	if branchFlags.deleteMergedClsFlag {
   150  		return deleteMergedBranches(jirix, branch, true)
   151  	}
   152  	if branchFlags.deleteMergedFlag {
   153  		return deleteMergedBranches(jirix, branch, false)
   154  	}
   155  	return displayProjects(jirix, branch)
   156  }
   157  
   158  var (
   159  	changeIDRE = regexp.MustCompile("Change-Id: (I[0123456789abcdefABCDEF]{40})")
   160  )
   161  
   162  func deleteMergedBranches(jirix *jiri.X, branchToDelete string, deleteMergedCls bool) error {
   163  	localProjects, err := project.LocalProjects(jirix, project.FastScan)
   164  	if err != nil {
   165  		return err
   166  	}
   167  
   168  	cDir, err := os.Getwd()
   169  	if err != nil {
   170  		return err
   171  	}
   172  
   173  	jirix.TimerPush("Get states")
   174  	states, err := project.GetProjectStates(jirix, localProjects, false)
   175  	if err != nil {
   176  		return err
   177  	}
   178  	jirix.TimerPop()
   179  
   180  	remoteProjects, _, _, err := project.LoadManifestFile(jirix, jirix.JiriManifestFile(), localProjects, false /*localManifest*/)
   181  	if err != nil {
   182  		return err
   183  	}
   184  
   185  	jirix.TimerPush("Process")
   186  	processProject := func(key project.ProjectKey) {
   187  		state, _ := states[key]
   188  		remote, ok := remoteProjects[key]
   189  		relativePath, err := filepath.Rel(cDir, state.Project.Path)
   190  		if err != nil {
   191  			relativePath = state.Project.Path
   192  		}
   193  		if !branchFlags.overrideProjectConfigFlag && (state.Project.LocalConfig.Ignore || state.Project.LocalConfig.NoUpdate) {
   194  			jirix.Logger.Warningf(" Not processing project %s(%s) due to it's local-config. Use '-overrride-pc' flag\n\n", state.Project.Name, state.Project.Path)
   195  			return
   196  		}
   197  		if !ok {
   198  			jirix.Logger.Debugf("Not processing project %s(%s) as it was not found in manifest\n\n", state.Project.Name, relativePath)
   199  			return
   200  		}
   201  
   202  		deletedBranches, mErr := deleteProjectMergedBranches(jirix, state.Project, remote, relativePath, branchToDelete)
   203  		if deleteMergedCls {
   204  			deletedBranches2, err2 := deleteProjectMergedClsBranches(jirix, state.Project, remote, relativePath, branchToDelete)
   205  			for b, h := range deletedBranches2 {
   206  				deletedBranches[b] = h
   207  			}
   208  			mErr = append(mErr, err2...)
   209  		}
   210  
   211  		if len(deletedBranches) != 0 || mErr != nil {
   212  			buf := fmt.Sprintf("Project: %s(%s)\n", state.Project.Name, relativePath)
   213  			if len(deletedBranches) != 0 {
   214  				dbs := []string{}
   215  				for b, h := range deletedBranches {
   216  					dbs = append(dbs, fmt.Sprintf("%s(%s)", b, h))
   217  				}
   218  				buf = buf + fmt.Sprintf("%s: %s\n", jirix.Color.Green("Deleted branch(es)"), strings.Join(dbs, ", "))
   219  
   220  				if _, ok := deletedBranches[state.CurrentBranch.Name]; ok {
   221  					buf = buf + fmt.Sprintf("Current branch \"%s\" was deleted and project was put on JIRI_HEAD\n", jirix.Color.Yellow(state.CurrentBranch.Name))
   222  				}
   223  			}
   224  			if mErr != nil {
   225  				jirix.IncrementFailures()
   226  				buf = buf + fmt.Sprintf("%s\n", mErr)
   227  				jirix.Logger.Errorf("%s\n", buf)
   228  			} else {
   229  				jirix.Logger.Infof("%s\n", buf)
   230  			}
   231  		}
   232  	}
   233  
   234  	workQueue := make(chan project.ProjectKey, len(states))
   235  	for key := range states {
   236  		workQueue <- key
   237  	}
   238  	close(workQueue)
   239  
   240  	var wg sync.WaitGroup
   241  	for i := uint(0); i < jirix.Jobs; i++ {
   242  		wg.Add(1)
   243  		go func() {
   244  			defer wg.Done()
   245  			for key := range workQueue {
   246  				processProject(key)
   247  			}
   248  		}()
   249  	}
   250  
   251  	wg.Wait()
   252  	jirix.TimerPop()
   253  
   254  	if jirix.Failures() != 0 {
   255  		return fmt.Errorf("Branch deletion completed with non-fatal errors.")
   256  	}
   257  	return nil
   258  }
   259  
   260  func deleteProjectMergedClsBranches(jirix *jiri.X, local project.Project, remote project.Project, relativePath, branchToDelete string) (map[string]string, MultiError) {
   261  	deletedBranches := make(map[string]string)
   262  	var retErr MultiError
   263  	if remote.GerritHost == "" {
   264  		return nil, nil
   265  	}
   266  	hostUrl, err := url.Parse(remote.GerritHost)
   267  	if err != nil {
   268  		retErr = append(retErr, err)
   269  		return nil, retErr
   270  	}
   271  	gerrit := gerrit.New(jirix, hostUrl)
   272  	scm := gitutil.New(jirix, gitutil.RootDirOpt(local.Path))
   273  	branches, err := scm.GetAllBranchesInfo()
   274  	if err != nil {
   275  		retErr = append(retErr, err)
   276  		return nil, retErr
   277  	}
   278  	for _, b := range branches {
   279  		if branchToDelete != "" && b.Name != branchToDelete {
   280  			continue
   281  		}
   282  		// Only show this message when project has some local branch
   283  		if strings.HasPrefix(local.Remote, "sso://") {
   284  			jirix.Logger.Warningf("Skipping project %s(%s) as it uses sso protocol. Not querying gerrit\n\n", local.Name, relativePath)
   285  			return nil, nil
   286  		}
   287  		if b.IsHead {
   288  			untracked, err := scm.HasUntrackedFiles()
   289  			if err != nil {
   290  				retErr = append(retErr, fmt.Errorf("Not deleting current branch %q as can't get changes: %s\n", b.Name, err))
   291  				continue
   292  			}
   293  			uncommited, err := scm.HasUncommittedChanges()
   294  			if err != nil {
   295  				retErr = append(retErr, fmt.Errorf("Not deleting current branch %q as can't get changes: %s\n", b.Name, err))
   296  				continue
   297  			}
   298  			if untracked || uncommited {
   299  				jirix.Logger.Debugf("Not deleting current branch %q for project %s(%s) as it has changes\n\n", b.Name, local.Name, relativePath)
   300  				continue
   301  			}
   302  		}
   303  
   304  		trackingBranch := ""
   305  		if b.Tracking == nil {
   306  			rb := remote.RemoteBranch
   307  			if rb == "" {
   308  				rb = "main"
   309  			}
   310  			trackingBranch = fmt.Sprintf("remotes/origin/%s", rb)
   311  		} else {
   312  			trackingBranch = b.Tracking.Name
   313  		}
   314  
   315  		extraCommits, err := scm.ExtraCommits(b.Name, trackingBranch)
   316  		if err != nil {
   317  			retErr = append(retErr, fmt.Errorf("Not deleting branch %q as can't get extra commits: %s\n", b.Name, err))
   318  			continue
   319  		}
   320  
   321  		if len(extraCommits) > 10 {
   322  			jirix.Logger.Debugf("Not deleting branch %q for project %s(%s) as it has more than 10 extra commits\n\n", b.Name, local.Name, relativePath)
   323  			continue
   324  		}
   325  
   326  		deleteBranch := true
   327  		for _, c := range extraCommits {
   328  			deleteBranch = false
   329  			log, err := scm.CommitMsg(c)
   330  			if err != nil {
   331  				retErr = append(retErr, fmt.Errorf("Not deleting branch %q as can't get log for rev %q: %s\n", b.Name, c, err))
   332  				break
   333  			}
   334  			changeID := changeIDRE.FindStringSubmatch(log)
   335  			if len(changeID) != 2 {
   336  				// Invalid/No Changeid
   337  				break
   338  			}
   339  			c, err := gerrit.GetChangeByID(changeID[1])
   340  			if err != nil {
   341  				retErr = append(retErr, fmt.Errorf("Not deleting branch %q as can't get change %q: %s\n", b.Name, changeID[1], err))
   342  				break
   343  			}
   344  			if c == nil || c.Submitted == "" {
   345  				// Not merged
   346  				break
   347  			}
   348  			deleteBranch = true
   349  		}
   350  		if !deleteBranch {
   351  			continue
   352  		}
   353  
   354  		if b.IsHead {
   355  			revision, err := project.GetHeadRevision(remote)
   356  			if err != nil {
   357  				retErr = append(retErr, fmt.Errorf("Not deleting current branch %q as can't get head revision: %s\n", b.Name, err))
   358  				continue
   359  			}
   360  			if err := scm.CheckoutBranch(revision, (remote.GitSubmodules && jirix.EnableSubmodules), false, gitutil.DetachOpt(true)); err != nil {
   361  				retErr = append(retErr, fmt.Errorf("Not deleting current branch %q as can't checkout JIRI_HEAD: %s\n", b.Name, err))
   362  				continue
   363  			}
   364  		}
   365  
   366  		shortHash, err := scm.ShortHash(b.Revision)
   367  		if err != nil {
   368  			retErr = append(retErr, fmt.Errorf("Not deleting current branch %q as can't short hash: %s\n", b.Name, err))
   369  			continue
   370  		}
   371  		if err := scm.DeleteBranch(b.Name, gitutil.ForceOpt(true)); err != nil {
   372  			retErr = append(retErr, fmt.Errorf("Cannot delete branch %q: %s\n", b.Name, err))
   373  			if b.IsHead {
   374  				if err := scm.CheckoutBranch(b.Name, (remote.GitSubmodules && jirix.EnableSubmodules), false); err != nil {
   375  					retErr = append(retErr, fmt.Errorf("Not able to put project back on branch %q: %s\n", b.Name, err))
   376  				}
   377  			}
   378  			continue
   379  		}
   380  		deletedBranches[b.Name] = shortHash
   381  	}
   382  	return deletedBranches, retErr
   383  }
   384  
   385  func deleteProjectMergedBranches(jirix *jiri.X, local project.Project, remote project.Project, relativePath, branchToDelete string) (map[string]string, MultiError) {
   386  	deletedBranches := make(map[string]string)
   387  	var retErr MultiError
   388  	var mergedBranches map[string]bool
   389  	scm := gitutil.New(jirix, gitutil.RootDirOpt(local.Path))
   390  	branches, err := scm.GetAllBranchesInfo()
   391  	if err != nil {
   392  		retErr = append(retErr, err)
   393  		return nil, retErr
   394  	}
   395  	for _, b := range branches {
   396  		if branchToDelete != "" && b.Name != branchToDelete {
   397  			continue
   398  		}
   399  		deleteForced := false
   400  
   401  		if b.Tracking == nil {
   402  			// check if this branch is merged
   403  			if mergedBranches == nil {
   404  				// populate
   405  				mergedBranches = make(map[string]bool)
   406  				rb := remote.RemoteBranch
   407  				if rb == "" {
   408  					rb = "main"
   409  				}
   410  				if mbs, err := scm.MergedBranches("remotes/origin/" + rb); err != nil {
   411  					retErr = append(retErr, fmt.Errorf("Not able to get merged un-tracked branches: %s\n", err))
   412  					continue
   413  				} else {
   414  					for _, mb := range mbs {
   415  						mergedBranches[mb] = true
   416  					}
   417  				}
   418  			}
   419  			if !mergedBranches[b.Name] {
   420  				continue
   421  			}
   422  			deleteForced = true
   423  		}
   424  
   425  		if b.IsHead {
   426  			untracked, err := scm.HasUntrackedFiles()
   427  			if err != nil {
   428  				retErr = append(retErr, fmt.Errorf("Not deleting current branch %q as can't get changes: %s\n", b.Name, err))
   429  				continue
   430  			}
   431  			uncommited, err := scm.HasUncommittedChanges()
   432  			if err != nil {
   433  				retErr = append(retErr, fmt.Errorf("Not deleting current branch %q as can't get changes: %s\n", b.Name, err))
   434  				continue
   435  			}
   436  			if untracked || uncommited {
   437  				jirix.Logger.Debugf("Not deleting current branch %q for project %s(%s) as it has changes\n\n", b.Name, local.Name, relativePath)
   438  				continue
   439  			}
   440  			revision, err := project.GetHeadRevision(remote)
   441  			if err != nil {
   442  				retErr = append(retErr, fmt.Errorf("Not deleting current branch %q as can't get head revision: %s\n", b.Name, err))
   443  				continue
   444  			}
   445  			if err := scm.CheckoutBranch(revision, (remote.GitSubmodules && jirix.EnableSubmodules), false, gitutil.DetachOpt(true)); err != nil {
   446  				retErr = append(retErr, fmt.Errorf("Not deleting current branch %q as can't checkout JIRI_HEAD: %s\n", b.Name, err))
   447  				continue
   448  			}
   449  		}
   450  
   451  		shortHash, err := scm.ShortHash(b.Revision)
   452  		if err != nil {
   453  			retErr = append(retErr, fmt.Errorf("Not deleting current branch %q as can't short hash: %s\n", b.Name, err))
   454  			continue
   455  		}
   456  		if err := scm.DeleteBranch(b.Name, gitutil.ForceOpt(deleteForced)); err != nil {
   457  			if deleteForced {
   458  				retErr = append(retErr, fmt.Errorf("Cannot delete branch %q: %s\n", b.Name, err))
   459  			}
   460  			if b.IsHead {
   461  				if err := scm.CheckoutBranch(b.Name, (remote.GitSubmodules && jirix.EnableSubmodules), false); err != nil {
   462  					retErr = append(retErr, fmt.Errorf("Not able to put project back on branch %q: %s\n", b.Name, err))
   463  				}
   464  			}
   465  			continue
   466  		}
   467  		deletedBranches[b.Name] = shortHash
   468  	}
   469  	return deletedBranches, retErr
   470  }
   471  
   472  func deleteBranches(jirix *jiri.X, branchToDelete string) error {
   473  	localProjects, err := project.LocalProjects(jirix, project.FastScan)
   474  	if err != nil {
   475  		return err
   476  	}
   477  	cDir, err := os.Getwd()
   478  	if err != nil {
   479  		return err
   480  	}
   481  	states, err := project.GetProjectStates(jirix, localProjects, false)
   482  	if err != nil {
   483  		return err
   484  	}
   485  
   486  	jirix.TimerPush("Process")
   487  	errors := false
   488  	projectFound := false
   489  	var keys project.ProjectKeys
   490  	for key := range states {
   491  		keys = append(keys, key)
   492  	}
   493  	sort.Sort(keys)
   494  	for _, key := range keys {
   495  		state := states[key]
   496  		for _, branch := range state.Branches {
   497  			if branch.Name == branchToDelete {
   498  				projectFound = true
   499  				localProject := state.Project
   500  				relativePath, err := filepath.Rel(cDir, localProject.Path)
   501  				if err != nil {
   502  					return err
   503  				}
   504  				if !branchFlags.overrideProjectConfigFlag && (localProject.LocalConfig.Ignore || localProject.LocalConfig.NoUpdate) {
   505  					jirix.Logger.Warningf("Project %s(%s): branch %q won't be deleted due to it's local-config. Use '-overrride-pc' flag\n\n", localProject.Name, localProject.Path, branchToDelete)
   506  					break
   507  				}
   508  				fmt.Printf("Project %s(%s): ", localProject.Name, relativePath)
   509  				scm := gitutil.New(jirix, gitutil.RootDirOpt(localProject.Path))
   510  
   511  				if err := scm.DeleteBranch(branchToDelete, gitutil.ForceOpt(branchFlags.forceDeleteFlag)); err != nil {
   512  					errors = true
   513  					fmt.Printf(jirix.Color.Red("Error while deleting branch: %s\n", err))
   514  				} else {
   515  					shortHash, err := scm.ShortHash(branch.Revision)
   516  					if err != nil {
   517  						return err
   518  					}
   519  					fmt.Printf("%s (was %s)\n", jirix.Color.Green("Deleted Branch %s", branchToDelete), jirix.Color.Yellow(shortHash))
   520  				}
   521  				break
   522  			}
   523  		}
   524  	}
   525  	jirix.TimerPop()
   526  
   527  	if !projectFound {
   528  		fmt.Printf("Cannot find any project with branch %q\n", branchToDelete)
   529  		return nil
   530  	}
   531  	if errors {
   532  		fmt.Println(jirix.Color.Yellow("Please check errors above"))
   533  	}
   534  	return nil
   535  }