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 }