blob: 78dc065007b6e3ee73db7fb0e73c26aaab80e3c8 [file] [log] [blame]
// Copyright 2017 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package main
import (
"fmt"
"net/url"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"sync"
"go.fuchsia.dev/jiri"
"go.fuchsia.dev/jiri/cmdline"
"go.fuchsia.dev/jiri/gerrit"
"go.fuchsia.dev/jiri/gitutil"
"go.fuchsia.dev/jiri/project"
)
var branchFlags struct {
deleteFlag bool
deleteMergedClsFlag bool
deleteMergedFlag bool
forceDeleteFlag bool
listFlag bool
overrideProjectConfigFlag bool
}
type MultiError []error
func (m MultiError) Error() string {
s := []string{}
for _, e := range m {
if e != nil {
s = append(s, e.Error())
}
}
return strings.Join(s, "\n")
}
func (m MultiError) String() string {
return m.Error()
}
var cmdBranch = &cmdline.Command{
Runner: jiri.RunnerFunc(runBranch),
Name: "branch",
Short: "Show or delete branches",
Long: `
Show all the projects having branch <branch> .If -d or -D is passed, <branch>
is deleted. if <branch> is not passed, show all projects which have branches other than "master"`,
ArgsName: "<branch>",
ArgsLong: "<branch> is the name branch",
}
func init() {
flags := &cmdBranch.Flags
flags.BoolVar(&branchFlags.deleteFlag, "d", false, "Delete branch from project. Similar to running 'git branch -d <branch-name>'")
flags.BoolVar(&branchFlags.forceDeleteFlag, "D", false, "Force delete branch from project. Similar to running 'git branch -D <branch-name>'")
flags.BoolVar(&branchFlags.listFlag, "list", false, "Show only projects with current branch <branch>")
flags.BoolVar(&branchFlags.overrideProjectConfigFlag, "override-pc", false, "Overrrides project config's ignore and noupdate flag and deletes the branch.")
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 master). If <branch> is provided, it will only delete branch <branch> if merged.")
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.")
}
func displayProjects(jirix *jiri.X, branch string) error {
localProjects, err := project.LocalProjects(jirix, project.FastScan)
if err != nil {
return err
}
jirix.TimerPush("Get states")
states, err := project.GetProjectStates(jirix, localProjects, false)
if err != nil {
return err
}
jirix.TimerPop()
cDir, err := os.Getwd()
if err != nil {
return err
}
var keys project.ProjectKeys
for key := range states {
keys = append(keys, key)
}
sort.Sort(keys)
for _, key := range keys {
state := states[key]
relativePath, err := filepath.Rel(cDir, state.Project.Path)
if err != nil {
return err
}
if branch == "" {
var branches []string
master := ""
for _, b := range state.Branches {
name := b.Name
if state.CurrentBranch.Name == b.Name {
name = "*" + jirix.Color.Green("%s", b.Name)
}
if b.Name != "master" {
branches = append(branches, name)
} else {
master = name
}
}
if len(branches) != 0 {
if master != "" {
branches = append(branches, master)
}
fmt.Printf("%s: %s(%s)\n", jirix.Color.Yellow("Project"), state.Project.Name, relativePath)
fmt.Printf("%s: %s\n\n", jirix.Color.Yellow("Branch(es)"), strings.Join(branches, ", "))
}
} else if branchFlags.listFlag {
if state.CurrentBranch.Name == branch {
fmt.Printf("%s(%s)\n", state.Project.Name, relativePath)
}
} else {
for _, b := range state.Branches {
if b.Name == branch {
fmt.Printf("%s(%s)\n", state.Project.Name, relativePath)
break
}
}
}
}
jirix.TimerPop()
return nil
}
func runBranch(jirix *jiri.X, args []string) error {
branch := ""
if len(args) > 1 {
return jirix.UsageErrorf("Please provide only one branch")
} else if len(args) == 1 {
branch = args[0]
}
if branchFlags.deleteFlag || branchFlags.forceDeleteFlag {
if branch == "" {
return jirix.UsageErrorf("Please provide branch to delete")
}
return deleteBranches(jirix, branch)
}
if branchFlags.deleteMergedClsFlag {
return deleteMergedBranches(jirix, branch, true)
}
if branchFlags.deleteMergedFlag {
return deleteMergedBranches(jirix, branch, false)
}
return displayProjects(jirix, branch)
}
var (
changeIDRE = regexp.MustCompile("Change-Id: (I[0123456789abcdefABCDEF]{40})")
)
func deleteMergedBranches(jirix *jiri.X, branchToDelete string, deleteMergedCls bool) error {
localProjects, err := project.LocalProjects(jirix, project.FastScan)
if err != nil {
return err
}
cDir, err := os.Getwd()
if err != nil {
return err
}
jirix.TimerPush("Get states")
states, err := project.GetProjectStates(jirix, localProjects, false)
if err != nil {
return err
}
jirix.TimerPop()
remoteProjects, _, _, err := project.LoadManifestFile(jirix, jirix.JiriManifestFile(), localProjects, false /*localManifest*/)
if err != nil {
return err
}
jirix.TimerPush("Process")
processProject := func(key project.ProjectKey) {
state, _ := states[key]
remote, ok := remoteProjects[key]
relativePath, err := filepath.Rel(cDir, state.Project.Path)
if err != nil {
relativePath = state.Project.Path
}
if !branchFlags.overrideProjectConfigFlag && (state.Project.LocalConfig.Ignore || state.Project.LocalConfig.NoUpdate) {
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)
return
}
if !ok {
jirix.Logger.Debugf("Not processing project %s(%s) as it was not found in manifest\n\n", state.Project.Name, relativePath)
return
}
deletedBranches, mErr := deleteProjectMergedBranches(jirix, state.Project, remote, relativePath, branchToDelete)
if deleteMergedCls {
deletedBranches2, err2 := deleteProjectMergedClsBranches(jirix, state.Project, remote, relativePath, branchToDelete)
for b, h := range deletedBranches2 {
deletedBranches[b] = h
}
mErr = append(mErr, err2...)
}
if len(deletedBranches) != 0 || mErr != nil {
buf := fmt.Sprintf("Project: %s(%s)\n", state.Project.Name, relativePath)
if len(deletedBranches) != 0 {
dbs := []string{}
for b, h := range deletedBranches {
dbs = append(dbs, fmt.Sprintf("%s(%s)", b, h))
}
buf = buf + fmt.Sprintf("%s: %s\n", jirix.Color.Green("Deleted branch(es)"), strings.Join(dbs, ", "))
if _, ok := deletedBranches[state.CurrentBranch.Name]; ok {
buf = buf + fmt.Sprintf("Current branch \"%s\" was deleted and project was put on JIRI_HEAD\n", jirix.Color.Yellow(state.CurrentBranch.Name))
}
}
if mErr != nil {
jirix.IncrementFailures()
buf = buf + fmt.Sprintf("%s\n", mErr)
jirix.Logger.Errorf("%s\n", buf)
} else {
jirix.Logger.Infof("%s\n", buf)
}
}
}
workQueue := make(chan project.ProjectKey, len(states))
for key := range states {
workQueue <- key
}
close(workQueue)
var wg sync.WaitGroup
for i := uint(0); i < jirix.Jobs; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for key := range workQueue {
processProject(key)
}
}()
}
wg.Wait()
jirix.TimerPop()
if jirix.Failures() != 0 {
return fmt.Errorf("Branch deletion completed with non-fatal errors.")
}
return nil
}
func deleteProjectMergedClsBranches(jirix *jiri.X, local project.Project, remote project.Project, relativePath, branchToDelete string) (map[string]string, MultiError) {
deletedBranches := make(map[string]string)
var retErr MultiError
if remote.GerritHost == "" {
return nil, nil
}
hostUrl, err := url.Parse(remote.GerritHost)
if err != nil {
retErr = append(retErr, err)
return nil, retErr
}
gerrit := gerrit.New(jirix, hostUrl)
scm := gitutil.New(jirix, gitutil.RootDirOpt(local.Path))
branches, err := scm.GetAllBranchesInfo()
if err != nil {
retErr = append(retErr, err)
return nil, retErr
}
for _, b := range branches {
if branchToDelete != "" && b.Name != branchToDelete {
continue
}
// Only show this message when project has some local branch
if strings.HasPrefix(local.Remote, "sso://") {
jirix.Logger.Warningf("Skipping project %s(%s) as it uses sso protocol. Not querying gerrit\n\n", local.Name, relativePath)
return nil, nil
}
if b.IsHead {
untracked, err := scm.HasUntrackedFiles()
if err != nil {
retErr = append(retErr, fmt.Errorf("Not deleting current branch %q as can't get changes: %s\n", b.Name, err))
continue
}
uncommited, err := scm.HasUncommittedChanges()
if err != nil {
retErr = append(retErr, fmt.Errorf("Not deleting current branch %q as can't get changes: %s\n", b.Name, err))
continue
}
if untracked || uncommited {
jirix.Logger.Debugf("Not deleting current branch %q for project %s(%s) as it has changes\n\n", b.Name, local.Name, relativePath)
continue
}
}
trackingBranch := ""
if b.Tracking == nil {
rb := remote.RemoteBranch
if rb == "" {
rb = "master"
}
trackingBranch = fmt.Sprintf("remotes/origin/%s", rb)
} else {
trackingBranch = b.Tracking.Name
}
extraCommits, err := scm.ExtraCommits(b.Name, trackingBranch)
if err != nil {
retErr = append(retErr, fmt.Errorf("Not deleting branch %q as can't get extra commits: %s\n", b.Name, err))
continue
}
if len(extraCommits) > 10 {
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)
continue
}
deleteBranch := true
for _, c := range extraCommits {
deleteBranch = false
log, err := scm.CommitMsg(c)
if err != nil {
retErr = append(retErr, fmt.Errorf("Not deleting branch %q as can't get log for rev %q: %s\n", b.Name, c, err))
break
}
changeID := changeIDRE.FindStringSubmatch(log)
if len(changeID) != 2 {
// Invalid/No Changeid
break
}
c, err := gerrit.GetChangeByID(changeID[1])
if err != nil {
retErr = append(retErr, fmt.Errorf("Not deleting branch %q as can't get change %q: %s\n", b.Name, changeID[1], err))
break
}
if c == nil || c.Submitted == "" {
// Not merged
break
}
deleteBranch = true
}
if !deleteBranch {
continue
}
if b.IsHead {
revision, err := project.GetHeadRevision(remote)
if err != nil {
retErr = append(retErr, fmt.Errorf("Not deleting current branch %q as can't get head revision: %s\n", b.Name, err))
continue
}
if err := scm.CheckoutBranch(revision, (remote.GitSubmodules && jirix.EnableSubmodules), false, gitutil.DetachOpt(true)); err != nil {
retErr = append(retErr, fmt.Errorf("Not deleting current branch %q as can't checkout JIRI_HEAD: %s\n", b.Name, err))
continue
}
}
shortHash, err := scm.ShortHash(b.Revision)
if err != nil {
retErr = append(retErr, fmt.Errorf("Not deleting current branch %q as can't short hash: %s\n", b.Name, err))
continue
}
if err := scm.DeleteBranch(b.Name, gitutil.ForceOpt(true)); err != nil {
retErr = append(retErr, fmt.Errorf("Cannot delete branch %q: %s\n", b.Name, err))
if b.IsHead {
if err := scm.CheckoutBranch(b.Name, (remote.GitSubmodules && jirix.EnableSubmodules), false); err != nil {
retErr = append(retErr, fmt.Errorf("Not able to put project back on branch %q: %s\n", b.Name, err))
}
}
continue
}
deletedBranches[b.Name] = shortHash
}
return deletedBranches, retErr
}
func deleteProjectMergedBranches(jirix *jiri.X, local project.Project, remote project.Project, relativePath, branchToDelete string) (map[string]string, MultiError) {
deletedBranches := make(map[string]string)
var retErr MultiError
var mergedBranches map[string]bool
scm := gitutil.New(jirix, gitutil.RootDirOpt(local.Path))
branches, err := scm.GetAllBranchesInfo()
if err != nil {
retErr = append(retErr, err)
return nil, retErr
}
for _, b := range branches {
if branchToDelete != "" && b.Name != branchToDelete {
continue
}
deleteForced := false
if b.Tracking == nil {
// check if this branch is merged
if mergedBranches == nil {
// populate
mergedBranches = make(map[string]bool)
rb := remote.RemoteBranch
if rb == "" {
rb = "master"
}
if mbs, err := scm.MergedBranches("remotes/origin/" + rb); err != nil {
retErr = append(retErr, fmt.Errorf("Not able to get merged un-tracked branches: %s\n", err))
continue
} else {
for _, mb := range mbs {
mergedBranches[mb] = true
}
}
}
if !mergedBranches[b.Name] {
continue
}
deleteForced = true
}
if b.IsHead {
untracked, err := scm.HasUntrackedFiles()
if err != nil {
retErr = append(retErr, fmt.Errorf("Not deleting current branch %q as can't get changes: %s\n", b.Name, err))
continue
}
uncommited, err := scm.HasUncommittedChanges()
if err != nil {
retErr = append(retErr, fmt.Errorf("Not deleting current branch %q as can't get changes: %s\n", b.Name, err))
continue
}
if untracked || uncommited {
jirix.Logger.Debugf("Not deleting current branch %q for project %s(%s) as it has changes\n\n", b.Name, local.Name, relativePath)
continue
}
revision, err := project.GetHeadRevision(remote)
if err != nil {
retErr = append(retErr, fmt.Errorf("Not deleting current branch %q as can't get head revision: %s\n", b.Name, err))
continue
}
if err := scm.CheckoutBranch(revision, (remote.GitSubmodules && jirix.EnableSubmodules), false, gitutil.DetachOpt(true)); err != nil {
retErr = append(retErr, fmt.Errorf("Not deleting current branch %q as can't checkout JIRI_HEAD: %s\n", b.Name, err))
continue
}
}
shortHash, err := scm.ShortHash(b.Revision)
if err != nil {
retErr = append(retErr, fmt.Errorf("Not deleting current branch %q as can't short hash: %s\n", b.Name, err))
continue
}
if err := scm.DeleteBranch(b.Name, gitutil.ForceOpt(deleteForced)); err != nil {
if deleteForced {
retErr = append(retErr, fmt.Errorf("Cannot delete branch %q: %s\n", b.Name, err))
}
if b.IsHead {
if err := scm.CheckoutBranch(b.Name, (remote.GitSubmodules && jirix.EnableSubmodules), false); err != nil {
retErr = append(retErr, fmt.Errorf("Not able to put project back on branch %q: %s\n", b.Name, err))
}
}
continue
}
deletedBranches[b.Name] = shortHash
}
return deletedBranches, retErr
}
func deleteBranches(jirix *jiri.X, branchToDelete string) error {
localProjects, err := project.LocalProjects(jirix, project.FastScan)
if err != nil {
return err
}
cDir, err := os.Getwd()
if err != nil {
return err
}
states, err := project.GetProjectStates(jirix, localProjects, false)
if err != nil {
return err
}
jirix.TimerPush("Process")
errors := false
projectFound := false
var keys project.ProjectKeys
for key := range states {
keys = append(keys, key)
}
sort.Sort(keys)
for _, key := range keys {
state := states[key]
for _, branch := range state.Branches {
if branch.Name == branchToDelete {
projectFound = true
localProject := state.Project
relativePath, err := filepath.Rel(cDir, localProject.Path)
if err != nil {
return err
}
if !branchFlags.overrideProjectConfigFlag && (localProject.LocalConfig.Ignore || localProject.LocalConfig.NoUpdate) {
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)
break
}
fmt.Printf("Project %s(%s): ", localProject.Name, relativePath)
scm := gitutil.New(jirix, gitutil.RootDirOpt(localProject.Path))
if err := scm.DeleteBranch(branchToDelete, gitutil.ForceOpt(branchFlags.forceDeleteFlag)); err != nil {
errors = true
fmt.Printf(jirix.Color.Red("Error while deleting branch: %s\n", err))
} else {
shortHash, err := scm.ShortHash(branch.Revision)
if err != nil {
return err
}
fmt.Printf("%s (was %s)\n", jirix.Color.Green("Deleted Branch %s", branchToDelete), jirix.Color.Yellow(shortHash))
}
break
}
}
}
jirix.TimerPop()
if !projectFound {
fmt.Printf("Cannot find any project with branch %q\n", branchToDelete)
return nil
}
if errors {
fmt.Println(jirix.Color.Yellow("Please check errors above"))
}
return nil
}