1package git 2 3import ( 4 "fmt" 5 "io/ioutil" 6 "os" 7 "path/filepath" 8 "regexp" 9 "sort" 10 "strconv" 11 "strings" 12 13 "github.com/git-town/git-town/src/config" 14 "github.com/git-town/git-town/src/run" 15 "github.com/git-town/git-town/src/stringslice" 16) 17 18// Runner executes Git commands. 19type Runner struct { 20 run.Shell // for running console commands 21 Config *config.Config // caches Git configuration settings 22 CurrentBranchCache *StringCache // caches the currently checked out Git branch 23 DryRun *DryRun // tracks dry-run information 24 IsRepoCache *BoolCache // caches whether the current directory is a Git repo 25 RemoteBranchCache *StringSliceCache // caches the remote branches of this Git repo 26 RemotesCache *StringSliceCache // caches Git remotes 27 RootDirCache *StringCache // caches the base of the Git directory 28} 29 30// AbortMerge cancels a currently ongoing Git merge operation. 31func (r *Runner) AbortMerge() error { 32 _, err := r.Run("git", "merge", "--abort") 33 if err != nil { 34 return fmt.Errorf("cannot abort current merge: %w", err) 35 } 36 return nil 37} 38 39// AbortRebase cancels a currently ongoing Git rebase operation. 40func (r *Runner) AbortRebase() error { 41 _, err := r.Run("git", "rebase", "--abort") 42 if err != nil { 43 return fmt.Errorf("cannot abort current merge: %w", err) 44 } 45 return nil 46} 47 48// AddRemote adds the given Git remote to this repository. 49func (r *Runner) AddRemote(name, value string) error { 50 _, err := r.Run("git", "remote", "add", name, value) 51 if err != nil { 52 return fmt.Errorf("cannot add remote %q --> %q: %w", name, value, err) 53 } 54 r.RemotesCache.Invalidate() 55 return nil 56} 57 58// Author returns the locally Git configured user. 59func (r *Runner) Author() (author string, err error) { 60 out, err := r.Run("git", "config", "user.name") 61 if err != nil { 62 return "", err 63 } 64 name := out.OutputSanitized() 65 out, err = r.Run("git", "config", "user.email") 66 if err != nil { 67 return "", err 68 } 69 email := out.OutputSanitized() 70 return name + " <" + email + ">", nil 71} 72 73// BranchHasUnmergedCommits indicates whether the branch with the given name 74// contains commits that are not merged into the main branch. 75func (r *Runner) BranchHasUnmergedCommits(branch string) (bool, error) { 76 out, err := r.Run("git", "log", r.Config.GetMainBranch()+".."+branch) 77 if err != nil { 78 return false, fmt.Errorf("cannot determine if branch %q has unmerged commits: %w", branch, err) 79 } 80 return out.OutputSanitized() != "", nil 81} 82 83// CheckoutBranch checks out the Git branch with the given name in this repo. 84func (r *Runner) CheckoutBranch(name string) error { 85 _, err := r.Run("git", "checkout", name) 86 if err != nil { 87 return fmt.Errorf("cannot check out branch %q in repo %q: %w", name, r.WorkingDir(), err) 88 } 89 if name != "-" { 90 r.CurrentBranchCache.Set(name) 91 } else { 92 r.CurrentBranchCache.Invalidate() 93 } 94 return nil 95} 96 97// CommentOutSquashCommitMessage comments out the message for the current squash merge 98// Adds the given prefix with the newline if provided. 99func (r *Runner) CommentOutSquashCommitMessage(prefix string) error { 100 squashMessageFile := ".git/SQUASH_MSG" 101 contentBytes, err := ioutil.ReadFile(squashMessageFile) 102 if err != nil { 103 return fmt.Errorf("cannot read squash message file %q: %w", squashMessageFile, err) 104 } 105 content := string(contentBytes) 106 if prefix != "" { 107 content = prefix + "\n" + content 108 } 109 content = regexp.MustCompile("(?m)^").ReplaceAllString(content, "# ") 110 return ioutil.WriteFile(squashMessageFile, []byte(content), 0600) 111} 112 113// CommitNoEdit commits all staged files with the default commit message. 114func (r *Runner) CommitNoEdit() error { 115 _, err := r.Run("git", "commit", "--no-edit") 116 if err != nil { 117 return fmt.Errorf("cannot commit files: %w", err) 118 } 119 return nil 120} 121 122// Commits provides a list of the commits in this Git repository with the given fields. 123func (r *Runner) Commits(fields []string) (result []Commit, err error) { 124 branches, err := r.LocalBranchesMainFirst() 125 if err != nil { 126 return result, fmt.Errorf("cannot determine the Git branches: %w", err) 127 } 128 for _, branch := range branches { 129 commits, err := r.CommitsInBranch(branch, fields) 130 if err != nil { 131 return result, err 132 } 133 result = append(result, commits...) 134 } 135 return result, nil 136} 137 138// CommitsInBranch provides all commits in the given Git branch. 139func (r *Runner) CommitsInBranch(branch string, fields []string) (result []Commit, err error) { 140 outcome, err := r.Run("git", "log", branch, "--format=%h|%s|%an <%ae>", "--topo-order", "--reverse") 141 if err != nil { 142 return result, fmt.Errorf("cannot get commits in branch %q: %w", branch, err) 143 } 144 for _, line := range strings.Split(outcome.OutputSanitized(), "\n") { 145 parts := strings.Split(line, "|") 146 commit := Commit{Branch: branch, SHA: parts[0], Message: parts[1], Author: parts[2]} 147 if strings.EqualFold(commit.Message, "initial commit") { 148 continue 149 } 150 if stringslice.Contains(fields, "FILE NAME") { 151 filenames, err := r.FilesInCommit(commit.SHA) 152 if err != nil { 153 return result, fmt.Errorf("cannot determine file name for commit %q in branch %q: %w", commit.SHA, branch, err) 154 } 155 commit.FileName = strings.Join(filenames, ", ") 156 } 157 if stringslice.Contains(fields, "FILE CONTENT") { 158 filecontent, err := r.FileContentInCommit(commit.SHA, commit.FileName) 159 if err != nil { 160 return result, fmt.Errorf("cannot determine file content for commit %q in branch %q: %w", commit.SHA, branch, err) 161 } 162 commit.FileContent = filecontent 163 } 164 result = append(result, commit) 165 } 166 return result, nil 167} 168 169// CommitStagedChanges commits the currently staged changes. 170func (r *Runner) CommitStagedChanges(message string) error { 171 var err error 172 if message != "" { 173 _, err = r.Run("git", "commit", "-m", message) 174 } else { 175 _, err = r.Run("git", "commit", "--no-edit") 176 } 177 if err != nil { 178 return fmt.Errorf("cannot commit staged changes: %w", err) 179 } 180 return nil 181} 182 183// CommitWithMessageAndAuthor . 184func (r *Runner) CommitWithMessageAndAuthor(message, author string) error { 185 _, err := r.Run("git", "commit", "-m", message, "--author", author) 186 if err != nil { 187 return fmt.Errorf("cannot commit with message %q and author %q: %w", message, author, err) 188 } 189 return nil 190} 191 192// CommitWithMessage commits the staged changes with the given commit message. 193func (r *Runner) CommitWithMessage(message string) error { 194 _, err := r.Run("git", "commit", "-m", message) 195 if err != nil { 196 return fmt.Errorf("cannot commit with message %q: %w", message, err) 197 } 198 return nil 199} 200 201// Commit . 202func (r *Runner) Commit() error { 203 _, err := r.Run("git", "commit") 204 return err 205} 206 207// ConnectTrackingBranch connects the branch with the given name to its remote tracking branch. 208// The branch must exist. 209func (r *Runner) ConnectTrackingBranch(name string) error { 210 _, err := r.Run("git", "branch", "--set-upstream-to=origin/"+name, name) 211 if err != nil { 212 return fmt.Errorf("cannot connect tracking branch for %q: %w", name, err) 213 } 214 return nil 215} 216 217// ContinueRebase continues the currently ongoing rebase. 218func (r *Runner) ContinueRebase() error { 219 _, err := r.Run("git", "rebase", "--continue") 220 if err != nil { 221 return fmt.Errorf("cannot continue rebase: %w", err) 222 } 223 return nil 224} 225 226// CreateBranch creates a new branch with the given name. 227// The created branch is a normal branch. 228// To create feature branches, use CreateFeatureBranch. 229func (r *Runner) CreateBranch(name, parent string) error { 230 _, err := r.Run("git", "branch", name, parent) 231 if err != nil { 232 return fmt.Errorf("cannot create branch %q: %w", name, err) 233 } 234 return nil 235} 236 237// CreateChildFeatureBranch creates a branch with the given name and parent in this repository. 238// The parent branch must already exist. 239func (r *Runner) CreateChildFeatureBranch(name string, parent string) error { 240 err := r.CreateBranch(name, parent) 241 if err != nil { 242 return fmt.Errorf("cannot create child branch %q: %w", name, err) 243 } 244 _ = r.Config.SetParentBranch(name, parent) 245 return nil 246} 247 248// CreateCommit creates a commit with the given properties in this Git repo. 249func (r *Runner) CreateCommit(commit Commit) error { 250 err := r.CheckoutBranch(commit.Branch) 251 if err != nil { 252 return fmt.Errorf("cannot checkout branch %q: %w", commit.Branch, err) 253 } 254 err = r.CreateFile(commit.FileName, commit.FileContent) 255 if err != nil { 256 return fmt.Errorf("cannot create file %q needed for commit: %w", commit.FileName, err) 257 } 258 _, err = r.Run("git", "add", commit.FileName) 259 if err != nil { 260 return fmt.Errorf("cannot add file to commit: %w", err) 261 } 262 commands := []string{"commit", "-m", commit.Message} 263 if commit.Author != "" { 264 commands = append(commands, "--author="+commit.Author) 265 } 266 _, err = r.Run("git", commands...) 267 if err != nil { 268 return fmt.Errorf("cannot commit: %w", err) 269 } 270 return nil 271} 272 273// CreateFeatureBranch creates a feature branch with the given name in this repository. 274func (r *Runner) CreateFeatureBranch(name string) error { 275 err := r.RunMany([][]string{ 276 {"git", "branch", name, "main"}, 277 {"git", "config", "git-town-branch." + name + ".parent", "main"}, 278 }) 279 if err != nil { 280 return fmt.Errorf("cannot create feature branch %q: %w", name, err) 281 } 282 return nil 283} 284 285// CreateFeatureBranchNoParent creates a feature branch with no defined parent in this repository. 286func (r *Runner) CreateFeatureBranchNoParent(name string) error { 287 _, err := r.Run("git", "branch", name, "main") 288 if err != nil { 289 return fmt.Errorf("cannot create feature branch %q: %w", name, err) 290 } 291 return nil 292} 293 294// CreateFile creates a file with the given name and content in this repository. 295func (r *Runner) CreateFile(name, content string) error { 296 filePath := filepath.Join(r.WorkingDir(), name) 297 folderPath := filepath.Dir(filePath) 298 err := os.MkdirAll(folderPath, os.ModePerm) 299 if err != nil { 300 return fmt.Errorf("cannot create folder %q: %v", folderPath, err) 301 } 302 err = ioutil.WriteFile(filePath, []byte(content), 0500) 303 if err != nil { 304 return fmt.Errorf("cannot create file %q: %w", name, err) 305 } 306 return nil 307} 308 309// CreatePerennialBranches creates perennial branches with the given names in this repository. 310func (r *Runner) CreatePerennialBranches(names ...string) error { 311 for _, name := range names { 312 err := r.CreateBranch(name, "main") 313 if err != nil { 314 return fmt.Errorf("cannot create perennial branch %q in repo %q: %w", name, r.WorkingDir(), err) 315 } 316 } 317 return r.Config.AddToPerennialBranches(names...) 318} 319 320// CreateRemoteBranch creates a remote branch from the given local SHA. 321func (r *Runner) CreateRemoteBranch(localSha, branchName string) error { 322 _, err := r.Run("git", "push", "origin", localSha+":refs/heads/"+branchName) 323 if err != nil { 324 return fmt.Errorf("cannot create remote branch for local SHA %q: %w", localSha, err) 325 } 326 return nil 327} 328 329// CreateStandaloneTag creates a tag not on a branch. 330func (r *Runner) CreateStandaloneTag(name string) error { 331 return r.RunMany([][]string{ 332 {"git", "checkout", "-b", "temp"}, 333 {"touch", "a.txt"}, 334 {"git", "add", "-A"}, 335 {"git", "commit", "-m", "temp"}, 336 {"git", "tag", "-a", name, "-m", name}, 337 {"git", "checkout", "-"}, 338 {"git", "branch", "-D", "temp"}, 339 }) 340} 341 342// CreateTag creates a tag with the given name. 343func (r *Runner) CreateTag(name string) error { 344 _, err := r.Run("git", "tag", "-a", name, "-m", name) 345 return err 346} 347 348// CreateTrackingBranch creates a remote tracking branch for the given local branch. 349func (r *Runner) CreateTrackingBranch(branch string) error { 350 _, err := r.Run("git", "push", "-u", "origin", branch) 351 if err != nil { 352 return fmt.Errorf("cannot create tracking branch for %q: %w", branch, err) 353 } 354 return nil 355} 356 357// CurrentBranch provides the currently checked out branch for this repo. 358func (r *Runner) CurrentBranch() (result string, err error) { 359 if r.DryRun.IsActive() { 360 return r.DryRun.CurrentBranch(), nil 361 } 362 if r.CurrentBranchCache.Initialized() { 363 return r.CurrentBranchCache.Value(), nil 364 } 365 rebasing, err := r.HasRebaseInProgress() 366 if err != nil { 367 return "", fmt.Errorf("cannot determine current branch: %w", err) 368 } 369 if rebasing { 370 currentBranch, err := r.currentBranchDuringRebase() 371 if err != nil { 372 return "", err 373 } 374 r.CurrentBranchCache.Set(currentBranch) 375 return currentBranch, nil 376 } 377 outcome, err := r.Run("git", "rev-parse", "--abbrev-ref", "HEAD") 378 if err != nil { 379 return "", fmt.Errorf("cannot determine the current branch: %w", err) 380 } 381 r.CurrentBranchCache.Set(outcome.OutputSanitized()) 382 return r.CurrentBranchCache.Value(), nil 383} 384 385func (r *Runner) currentBranchDuringRebase() (string, error) { 386 rootDir, err := r.RootDirectory() 387 if err != nil { 388 return "", err 389 } 390 rawContent, err := ioutil.ReadFile(fmt.Sprintf("%s/.git/rebase-apply/head-name", rootDir)) 391 if err != nil { 392 // Git 2.26 introduces a new rebase backend, see https://github.com/git/git/blob/master/Documentation/RelNotes/2.26.0.txt 393 rawContent, err = ioutil.ReadFile(fmt.Sprintf("%s/.git/rebase-merge/head-name", rootDir)) 394 if err != nil { 395 return "", err 396 } 397 } 398 content := strings.TrimSpace(string(rawContent)) 399 return strings.Replace(content, "refs/heads/", "", -1), nil 400} 401 402// CurrentSha provides the SHA of the currently checked out branch/commit. 403func (r *Runner) CurrentSha() (string, error) { 404 return r.ShaForBranch("HEAD") 405} 406 407// DeleteLastCommit resets HEAD to the previous commit. 408func (r *Runner) DeleteLastCommit() error { 409 _, err := r.Run("git", "reset", "--hard", "HEAD~1") 410 if err != nil { 411 return fmt.Errorf("cannot delete last commit: %w", err) 412 } 413 return nil 414} 415 416// DeleteLocalBranch removes the local branch with the given name. 417func (r *Runner) DeleteLocalBranch(name string, force bool) error { 418 args := []string{"branch", "-d", name} 419 if force { 420 args[1] = "-D" 421 } 422 _, err := r.Run("git", args...) 423 if err != nil { 424 return fmt.Errorf("cannot delete local branch %q: %w", name, err) 425 } 426 return nil 427} 428 429// DeleteMainBranchConfiguration removes the configuration for which branch is the main branch. 430func (r *Runner) DeleteMainBranchConfiguration() error { 431 _, err := r.Run("git", "config", "--unset", "git-town.main-branch-name") 432 if err != nil { 433 return fmt.Errorf("cannot delete main branch configuration: %w", err) 434 } 435 return nil 436} 437 438// DeleteRemoteBranch removes the remote branch of the given local branch. 439func (r *Runner) DeleteRemoteBranch(name string) error { 440 _, err := r.Run("git", "push", "origin", ":"+name) 441 if err != nil { 442 return fmt.Errorf("cannot delete tracking branch for %q: %w", name, err) 443 } 444 return nil 445} 446 447// DiffParent displays the diff between the given branch and its given parent branch. 448func (r *Runner) DiffParent(branch, parentBranch string) error { 449 _, err := r.Run("git", "diff", parentBranch+".."+branch) 450 if err != nil { 451 return fmt.Errorf("cannot diff branch %q with its parent branch %q: %w", branch, parentBranch, err) 452 } 453 return nil 454} 455 456// DiscardOpenChanges deletes all uncommitted changes. 457func (r *Runner) DiscardOpenChanges() error { 458 _, err := r.Run("git", "reset", "--hard") 459 if err != nil { 460 return fmt.Errorf("cannot discard open changes: %w", err) 461 } 462 return nil 463} 464 465// ExpectedPreviouslyCheckedOutBranch returns what is the expected previously checked out branch 466// given the inputs. 467func (r *Runner) ExpectedPreviouslyCheckedOutBranch(initialPreviouslyCheckedOutBranch, initialBranch string) (string, error) { 468 hasInitialPreviouslyCheckedOutBranch, err := r.HasLocalBranch(initialPreviouslyCheckedOutBranch) 469 if err != nil { 470 return "", err 471 } 472 if hasInitialPreviouslyCheckedOutBranch { 473 currentBranch, err := r.CurrentBranch() 474 if err != nil { 475 return "", err 476 } 477 hasInitialBranch, err := r.HasLocalBranch(initialBranch) 478 if err != nil { 479 return "", err 480 } 481 if currentBranch == initialBranch || !hasInitialBranch { 482 return initialPreviouslyCheckedOutBranch, nil 483 } 484 return initialBranch, nil 485 } 486 return r.Config.GetMainBranch(), nil 487} 488 489// Fetch retrieves the updates from the remote repo. 490func (r *Runner) Fetch() error { 491 _, err := r.Run("git", "fetch", "--prune", "--tags") 492 if err != nil { 493 return fmt.Errorf("cannot fetch: %w", err) 494 } 495 return nil 496} 497 498// FetchUpstream fetches updates from the upstream remote. 499func (r *Runner) FetchUpstream(branch string) error { 500 _, err := r.Run("git", "fetch", "upstream", branch) 501 if err != nil { 502 return fmt.Errorf("cannot fetch from upstream: %w", err) 503 } 504 return nil 505} 506 507// FileContent provides the current content of a file. 508func (r *Runner) FileContent(filename string) (result string, err error) { 509 content, err := ioutil.ReadFile(filepath.Join(r.WorkingDir(), filename)) 510 return string(content), err 511} 512 513// FileContentInCommit provides the content of the file with the given name in the commit with the given SHA. 514func (r *Runner) FileContentInCommit(sha string, filename string) (result string, err error) { 515 outcome, err := r.Run("git", "show", sha+":"+filename) 516 if err != nil { 517 return result, fmt.Errorf("cannot determine the content for file %q in commit %q: %w", filename, sha, err) 518 } 519 return outcome.OutputSanitized(), nil 520} 521 522// FilesInCommit provides the names of the files that the commit with the given SHA changes. 523func (r *Runner) FilesInCommit(sha string) (result []string, err error) { 524 outcome, err := r.Run("git", "diff-tree", "--no-commit-id", "--name-only", "-r", sha) 525 if err != nil { 526 return result, fmt.Errorf("cannot get files for commit %q: %w", sha, err) 527 } 528 return strings.Split(outcome.OutputSanitized(), "\n"), nil 529} 530 531// FilesInBranch provides the list of the files present in the given branch. 532func (r *Runner) FilesInBranch(branch string) (result []string, err error) { 533 outcome, err := r.Run("git", "ls-tree", "-r", "--name-only", branch) 534 if err != nil { 535 return result, fmt.Errorf("cannot determine files in branch %q in repo %q: %w", branch, r.WorkingDir(), err) 536 } 537 for _, line := range strings.Split(outcome.OutputSanitized(), "\n") { 538 file := strings.TrimSpace(line) 539 if file != "" { 540 result = append(result, file) 541 } 542 } 543 return result, err 544} 545 546// HasBranchesOutOfSync indicates whether one or more local branches are out of sync with their remote. 547func (r *Runner) HasBranchesOutOfSync() (bool, error) { 548 res, err := r.Run("git", "for-each-ref", "--format=%(refname:short) %(upstream:track)", "refs/heads") 549 if err != nil { 550 return false, fmt.Errorf("cannot determine if branches are out of sync in %q: %w %q", r.WorkingDir(), err, res.Output()) 551 } 552 return strings.Contains(res.Output(), "["), nil 553} 554 555// HasConflicts returns whether the local repository currently has unresolved merge conflicts. 556func (r *Runner) HasConflicts() (bool, error) { 557 res, err := r.Run("git", "status") 558 if err != nil { 559 return false, fmt.Errorf("cannot determine conflicts: %w", err) 560 } 561 return res.OutputContainsText("Unmerged paths"), nil 562} 563 564// HasFile indicates whether this repository contains a file with the given name and content. 565func (r *Runner) HasFile(name, content string) (result bool, err error) { 566 rawContent, err := ioutil.ReadFile(filepath.Join(r.WorkingDir(), name)) 567 if err != nil { 568 return result, fmt.Errorf("repo doesn't have file %q: %w", name, err) 569 } 570 actualContent := string(rawContent) 571 if actualContent != content { 572 return result, fmt.Errorf("file %q should have content %q but has %q", name, content, actualContent) 573 } 574 return true, nil 575} 576 577// HasGitTownConfigNow indicates whether this repository contain Git Town specific configuration. 578func (r *Runner) HasGitTownConfigNow() (result bool, err error) { 579 outcome, err := r.Run("git", "config", "--local", "--get-regex", "git-town") 580 if outcome.ExitCode() == 1 { 581 return false, nil 582 } 583 return outcome.OutputSanitized() != "", err 584} 585 586// HasLocalBranch indicates whether this repo has a local branch with the given name. 587func (r *Runner) HasLocalBranch(name string) (bool, error) { 588 branches, err := r.LocalBranchesMainFirst() 589 if err != nil { 590 return false, fmt.Errorf("cannot determine whether the local branch %q exists: %w", name, err) 591 } 592 return stringslice.Contains(branches, name), nil 593} 594 595// HasLocalOrRemoteBranch indicates whether this repo has a local or remote branch with the given name. 596func (r *Runner) HasLocalOrRemoteBranch(name string) (bool, error) { 597 branches, err := r.LocalAndRemoteBranches() 598 if err != nil { 599 return false, fmt.Errorf("cannot determine whether the local or remote branch %q exists: %w", name, err) 600 } 601 return stringslice.Contains(branches, name), nil 602} 603 604// HasMergeInProgress indicates whether this Git repository currently has a merge in progress. 605func (r *Runner) HasMergeInProgress() (result bool, err error) { 606 _, err = os.Stat(filepath.Join(r.WorkingDir(), ".git", "MERGE_HEAD")) 607 return err == nil, nil 608} 609 610// HasOpenChanges indicates whether this repo has open changes. 611func (r *Runner) HasOpenChanges() (bool, error) { 612 outcome, err := r.Run("git", "status", "--porcelain") 613 if err != nil { 614 return false, fmt.Errorf("cannot determine open changes: %w", err) 615 } 616 return outcome.OutputSanitized() != "", nil 617} 618 619// HasRebaseInProgress indicates whether this Git repository currently has a rebase in progress. 620func (r *Runner) HasRebaseInProgress() (bool, error) { 621 res, err := r.Run("git", "status") 622 if err != nil { 623 return false, fmt.Errorf("cannot determine rebase in %q progress: %w", r.WorkingDir(), err) 624 } 625 output := res.OutputSanitized() 626 if strings.Contains(output, "You are currently rebasing") { 627 return true, nil 628 } 629 if strings.Contains(output, "rebase in progress") { 630 return true, nil 631 } 632 return false, nil 633} 634 635// HasRemote indicates whether this repo has a remote with the given name. 636func (r *Runner) HasRemote(name string) (result bool, err error) { 637 remotes, err := r.Remotes() 638 if err != nil { 639 return false, fmt.Errorf("cannot determine if remote %q exists: %w", name, err) 640 } 641 return stringslice.Contains(remotes, name), nil 642} 643 644// HasShippableChanges indicates whether the given branch has changes 645// not currently in the main branch. 646func (r *Runner) HasShippableChanges(branch string) (bool, error) { 647 out, err := r.Run("git", "diff", r.Config.GetMainBranch()+".."+branch) 648 if err != nil { 649 return false, fmt.Errorf("cannot determine whether branch %q has shippable changes: %w", branch, err) 650 } 651 return out.OutputSanitized() != "", nil 652} 653 654// HasTrackingBranch indicates whether the local branch with the given name has a remote tracking branch. 655func (r *Runner) HasTrackingBranch(name string) (result bool, err error) { 656 trackingBranchName := "origin/" + name 657 remoteBranches, err := r.RemoteBranches() 658 if err != nil { 659 return false, fmt.Errorf("cannot determine if tracking branch %q exists: %w", name, err) 660 } 661 for _, line := range remoteBranches { 662 if strings.TrimSpace(line) == trackingBranchName { 663 return true, nil 664 } 665 } 666 return false, nil 667} 668 669// IsBranchInSync returns whether the branch with the given name is in sync with its tracking branch. 670func (r *Runner) IsBranchInSync(branchName string) (bool, error) { 671 hasTrackingBranch, err := r.HasTrackingBranch(branchName) 672 if err != nil { 673 return false, err 674 } 675 if hasTrackingBranch { 676 localSha, err := r.ShaForBranch(branchName) 677 if err != nil { 678 return false, err 679 } 680 remoteSha, err := r.ShaForBranch(r.TrackingBranchName(branchName)) 681 return localSha == remoteSha, err 682 } 683 return true, nil 684} 685 686// IsRepository returns whether or not the current directory is in a repository. 687func (r *Runner) IsRepository() bool { 688 if !r.IsRepoCache.Initialized() { 689 _, err := run.Exec("git", "rev-parse") 690 r.IsRepoCache.Set(err == nil) 691 } 692 return r.IsRepoCache.Value() 693} 694 695// LastCommitMessage returns the commit message for the last commit. 696func (r *Runner) LastCommitMessage() (string, error) { 697 out, err := r.Run("git", "log", "-1", "--format=%B") 698 if err != nil { 699 return "", fmt.Errorf("cannot determine last commit message: %w", err) 700 } 701 return out.OutputSanitized(), nil 702} 703 704// LocalAndRemoteBranches provides the names of all local branches in this repo. 705func (r *Runner) LocalAndRemoteBranches() ([]string, error) { 706 outcome, err := r.Run("git", "branch", "-a") 707 if err != nil { 708 return []string{}, fmt.Errorf("cannot determine the local branches") 709 } 710 lines := outcome.OutputLines() 711 branchNames := make(map[string]struct{}) 712 for l := range lines { 713 if !strings.Contains(lines[l], " -> ") { 714 branchNames[strings.TrimSpace(strings.Replace(strings.Replace(lines[l], "* ", "", 1), "remotes/origin/", "", 1))] = struct{}{} 715 } 716 } 717 result := make([]string, len(branchNames)) 718 i := 0 719 for branchName := range branchNames { 720 result[i] = branchName 721 i++ 722 } 723 sort.Strings(result) 724 return MainFirst(result), nil 725} 726 727// LocalBranches returns the names of all branches in the local repository, 728// ordered alphabetically. 729func (r *Runner) LocalBranches() (result []string, err error) { 730 res, err := r.Run("git", "branch") 731 if err != nil { 732 return result, err 733 } 734 for _, line := range res.OutputLines() { 735 line = strings.Trim(line, "* ") 736 line = strings.TrimSpace(line) 737 result = append(result, line) 738 } 739 return 740} 741 742// LocalBranchesMainFirst provides the names of all local branches in this repo. 743func (r *Runner) LocalBranchesMainFirst() (result []string, err error) { 744 branches, err := r.LocalBranches() 745 if err != nil { 746 return result, err 747 } 748 return MainFirst(sort.StringSlice(branches)), nil 749} 750 751// LocalBranchesWithDeletedTrackingBranches returns the names of all branches 752// whose remote tracking branches have been deleted. 753func (r *Runner) LocalBranchesWithDeletedTrackingBranches() (result []string, err error) { 754 res, err := r.Run("git", "branch", "-vv") 755 if err != nil { 756 return result, err 757 } 758 for _, line := range res.OutputLines() { 759 line = strings.Trim(line, "* ") 760 parts := strings.SplitN(line, " ", 2) 761 branchName := parts[0] 762 deleteTrackingBranchStatus := fmt.Sprintf("[%s: gone]", r.TrackingBranchName(branchName)) 763 if strings.Contains(parts[1], deleteTrackingBranchStatus) { 764 result = append(result, branchName) 765 } 766 } 767 return result, nil 768} 769 770// LocalBranchesWithoutMain returns the names of all branches in the local repository, 771// ordered alphabetically without the main branch. 772func (r *Runner) LocalBranchesWithoutMain() (result []string, err error) { 773 mainBranch := r.Config.GetMainBranch() 774 branches, err := r.LocalBranches() 775 if err != nil { 776 return result, err 777 } 778 for _, branch := range branches { 779 if branch != mainBranch { 780 result = append(result, branch) 781 } 782 } 783 return 784} 785 786// MergeBranchNoEdit merges the given branch into the current branch, 787// using the default commit message. 788func (r *Runner) MergeBranchNoEdit(branch string) error { 789 _, err := r.Run("git", "merge", "--no-edit", branch) 790 return err 791} 792 793// PopStash restores stashed-away changes into the workspace. 794func (r *Runner) PopStash() error { 795 _, err := r.Run("git", "stash", "pop") 796 if err != nil { 797 return fmt.Errorf("cannot pop the stash: %w", err) 798 } 799 return nil 800} 801 802// PreviouslyCheckedOutBranch provides the name of the branch that was previously checked out in this repo. 803func (r *Runner) PreviouslyCheckedOutBranch() (name string, err error) { 804 outcome, err := r.Run("git", "rev-parse", "--verify", "--abbrev-ref", "@{-1}") 805 if err != nil { 806 return "", fmt.Errorf("cannot determine the previously checked out branch: %w", err) 807 } 808 return outcome.OutputSanitized(), nil 809} 810 811// Pull fetches updates from the origin remote and updates the currently checked out branch. 812func (r *Runner) Pull() error { 813 _, err := r.Run("git", "pull") 814 if err != nil { 815 return fmt.Errorf("cannot pull updates: %w", err) 816 } 817 return nil 818} 819 820// PushBranch pushes the branch with the given name to the remote. 821func (r *Runner) PushBranch() error { 822 _, err := r.Run("git", "push") 823 if err != nil { 824 return fmt.Errorf("cannot push branch in repo %q to origin: %w", r.WorkingDir(), err) 825 } 826 return nil 827} 828 829// PushBranchForce pushes the branch with the given name to the remote. 830func (r *Runner) PushBranchForce(name string) error { 831 _, err := r.Run("git", "push", "-f", "origin", name) 832 if err != nil { 833 return fmt.Errorf("cannot force-push branch %q in repo %q to origin: %w", name, r.WorkingDir(), err) 834 } 835 return nil 836} 837 838// PushBranchSetUpstream pushes the branch with the given name to the remote. 839func (r *Runner) PushBranchSetUpstream(name string) error { 840 _, err := r.Run("git", "push", "-u", "origin", name) 841 if err != nil { 842 return fmt.Errorf("cannot push branch %q in repo %q to origin: %w", name, r.WorkingDir(), err) 843 } 844 return nil 845} 846 847// PushTags pushes new the Git tags to origin. 848func (r *Runner) PushTags() error { 849 _, err := r.Run("git", "push", "--tags") 850 if err != nil { 851 return fmt.Errorf("cannot push branch in repo %q: %w", r.WorkingDir(), err) 852 } 853 return nil 854} 855 856// Rebase initiates a Git rebase of the current branch against the given branch. 857func (r *Runner) Rebase(target string) error { 858 _, err := r.Run("git", "rebase", target) 859 if err != nil { 860 return fmt.Errorf("cannot rebase against branch %q: %w", target, err) 861 } 862 return nil 863} 864 865// RemoteBranches provides the names of the remote branches in this repo. 866func (r *Runner) RemoteBranches() ([]string, error) { 867 if !r.RemoteBranchCache.Initialized() { 868 outcome, err := r.Run("git", "branch", "-r") 869 if err != nil { 870 return []string{}, fmt.Errorf("cannot determine remote branches: %w", err) 871 } 872 lines := outcome.OutputLines() 873 branches := make([]string, 0, len(lines)-1) 874 for l := range lines { 875 if !strings.Contains(lines[l], " -> ") { 876 branches = append(branches, strings.TrimSpace(lines[l])) 877 } 878 } 879 r.RemoteBranchCache.Set(branches) 880 } 881 return r.RemoteBranchCache.Value(), nil 882} 883 884// Remotes provides the names of all Git remotes in this repository. 885func (r *Runner) Remotes() (result []string, err error) { 886 if !r.RemotesCache.initialized { 887 out, err := r.Run("git", "remote") 888 if err != nil { 889 return result, fmt.Errorf("cannot determine remotes: %w", err) 890 } 891 if out.OutputSanitized() == "" { 892 r.RemotesCache.Set([]string{}) 893 } else { 894 r.RemotesCache.Set(out.OutputLines()) 895 } 896 } 897 return r.RemotesCache.Value(), nil 898} 899 900// RemoveBranch deletes the branch with the given name from this repo. 901func (r *Runner) RemoveBranch(name string) error { 902 _, err := r.Run("git", "branch", "-D", name) 903 if err != nil { 904 return fmt.Errorf("cannot delete branch %q: %w", name, err) 905 } 906 return nil 907} 908 909// RemoveRemote deletes the Git remote with the given name. 910func (r *Runner) RemoveRemote(name string) error { 911 r.RemotesCache.Invalidate() 912 _, err := r.Run("git", "remote", "rm", name) 913 return err 914} 915 916// RemoveUnnecessaryFiles trims all files that aren't necessary in this repo. 917func (r *Runner) RemoveUnnecessaryFiles() error { 918 fullPath := filepath.Join(r.WorkingDir(), ".git", "hooks") 919 err := os.RemoveAll(fullPath) 920 if err != nil { 921 return fmt.Errorf("cannot remove unnecessary files in %q: %w", fullPath, err) 922 } 923 _ = os.Remove(filepath.Join(r.WorkingDir(), ".git", "COMMIT_EDITMSG")) 924 _ = os.Remove(filepath.Join(r.WorkingDir(), ".git", "description")) 925 return nil 926} 927 928// ResetToSha undoes all commits on the current branch all the way until the given SHA. 929func (r *Runner) ResetToSha(sha string, hard bool) error { 930 args := []string{"reset"} 931 if hard { 932 args = append(args, "--hard") 933 } 934 args = append(args, sha) 935 _, err := r.Run("git", args...) 936 if err != nil { 937 return fmt.Errorf("cannot reset to SHA %q: %w", sha, err) 938 } 939 return nil 940} 941 942// RevertCommit reverts the commit with the given SHA. 943func (r *Runner) RevertCommit(sha string) error { 944 _, err := r.Run("git", "revert", sha) 945 if err != nil { 946 return fmt.Errorf("cannot revert commit %q: %w", sha, err) 947 } 948 return nil 949} 950 951// RootDirectory returns the path of the rood directory of the current repository, 952// i.e. the directory that contains the ".git" folder. 953func (r *Runner) RootDirectory() (string, error) { 954 if !r.RootDirCache.Initialized() { 955 res, err := r.Run("git", "rev-parse", "--show-toplevel") 956 if err != nil { 957 return "", fmt.Errorf("cannot determine root directory: %w", err) 958 } 959 r.RootDirCache.Set(filepath.FromSlash(res.OutputSanitized())) 960 } 961 return r.RootDirCache.Value(), nil 962} 963 964// ShaForBranch provides the SHA for the local branch with the given name. 965func (r *Runner) ShaForBranch(name string) (sha string, err error) { 966 outcome, err := r.Run("git", "rev-parse", name) 967 if err != nil { 968 return "", fmt.Errorf("cannot determine SHA of local branch %q: %w", name, err) 969 } 970 return outcome.OutputSanitized(), nil 971} 972 973// ShaForCommit provides the SHA for the commit with the given name. 974func (r *Runner) ShaForCommit(name string) (result string, err error) { 975 var args []string 976 if name == "Initial commit" { 977 args = []string{"reflog", "--grep=" + name, "--format=%H", "--max-count=1"} 978 } else { 979 args = []string{"reflog", "--grep-reflog=commit: " + name, "--format=%H"} 980 } 981 res, err := r.Run("git", args...) 982 if err != nil { 983 return result, fmt.Errorf("cannot determine SHA of commit %q: %w", name, err) 984 } 985 if res.OutputSanitized() == "" { 986 return result, fmt.Errorf("cannot find the SHA of commit %q", name) 987 } 988 return res.OutputSanitized(), nil 989} 990 991// ShouldPushBranch returns whether the local branch with the given name 992// contains commits that have not been pushed to the remote. 993func (r *Runner) ShouldPushBranch(branch string) (bool, error) { 994 trackingBranch := r.TrackingBranchName(branch) 995 out, err := r.Run("git", "rev-list", "--left-right", branch+"..."+trackingBranch) 996 if err != nil { 997 return false, fmt.Errorf("cannot list diff of %q and %q: %w", branch, trackingBranch, err) 998 } 999 return out.OutputSanitized() != "", nil 1000} 1001 1002// SquashMerge squash-merges the given branch into the current branch. 1003func (r *Runner) SquashMerge(branch string) error { 1004 _, err := r.Run("git", "merge", "--squash", branch) 1005 if err != nil { 1006 return fmt.Errorf("cannot squash-merge branch %q: %w", branch, err) 1007 } 1008 return nil 1009} 1010 1011// Stash adds the current files to the Git stash. 1012func (r *Runner) Stash() error { 1013 err := r.RunMany([][]string{ 1014 {"git", "add", "-A"}, 1015 {"git", "stash"}, 1016 }) 1017 if err != nil { 1018 return fmt.Errorf("cannot stash: %w", err) 1019 } 1020 return nil 1021} 1022 1023// StashSize provides the number of stashes in this repository. 1024func (r *Runner) StashSize() (result int, err error) { 1025 res, err := r.Run("git", "stash", "list") 1026 if err != nil { 1027 return result, fmt.Errorf("command %q failed: %w", res.FullCmd(), err) 1028 } 1029 if res.OutputSanitized() == "" { 1030 return 0, nil 1031 } 1032 return len(res.OutputLines()), nil 1033} 1034 1035// Tags provides a list of the tags in this repository. 1036func (r *Runner) Tags() (result []string, err error) { 1037 res, err := r.Run("git", "tag") 1038 if err != nil { 1039 return result, fmt.Errorf("cannot determine tags in repo %q: %w", r.WorkingDir(), err) 1040 } 1041 for _, line := range strings.Split(res.OutputSanitized(), "\n") { 1042 result = append(result, strings.TrimSpace(line)) 1043 } 1044 return result, err 1045} 1046 1047// TrackingBranchName provides the name of the remote branch tracking the given local branch. 1048func (r *Runner) TrackingBranchName(branch string) string { 1049 return "origin/" + branch 1050} 1051 1052// UncommittedFiles provides the names of the files not committed into Git. 1053func (r *Runner) UncommittedFiles() (result []string, err error) { 1054 res, err := r.Run("git", "status", "--porcelain", "--untracked-files=all") 1055 if err != nil { 1056 return result, fmt.Errorf("cannot determine uncommitted files in %q: %w", r.WorkingDir(), err) 1057 } 1058 lines := res.OutputLines() 1059 for l := range lines { 1060 if lines[l] == "" { 1061 continue 1062 } 1063 parts := strings.Split(lines[l], " ") 1064 result = append(result, parts[1]) 1065 } 1066 return result, nil 1067} 1068 1069// StageFiles adds the file with the given name to the Git index. 1070func (r *Runner) StageFiles(names ...string) error { 1071 args := append([]string{"add"}, names...) 1072 _, err := r.Run("git", args...) 1073 if err != nil { 1074 return fmt.Errorf("cannot stage files %s: %w", strings.Join(names, ", "), err) 1075 } 1076 return nil 1077} 1078 1079// StartCommit starts a commit and stops at asking the user for the commit message. 1080func (r *Runner) StartCommit() error { 1081 _, err := r.Run("git", "commit") 1082 if err != nil { 1083 return fmt.Errorf("cannot start commit: %w", err) 1084 } 1085 return nil 1086} 1087 1088// Version indicates whether the needed Git version is installed. 1089func (r *Runner) Version() (major int, minor int, err error) { 1090 versionRegexp := regexp.MustCompile(`git version (\d+).(\d+).(\d+)`) 1091 res, err := r.Run("git", "version") 1092 if err != nil { 1093 return 0, 0, fmt.Errorf("cannot determine Git version: %w", err) 1094 } 1095 matches := versionRegexp.FindStringSubmatch(res.OutputSanitized()) 1096 if matches == nil { 1097 return 0, 0, fmt.Errorf("'git version' returned unexpected output: %q.\nPlease open an issue and supply the output of running 'git version'", res.Output()) 1098 } 1099 majorVersion, err := strconv.Atoi(matches[1]) 1100 if err != nil { 1101 return 0, 0, fmt.Errorf("cannot convert major version %q to int: %w", matches[1], err) 1102 } 1103 minorVersion, err := strconv.Atoi(matches[2]) 1104 if err != nil { 1105 return 0, 0, fmt.Errorf("cannot convert minor version %q to int: %w", matches[2], err) 1106 } 1107 return majorVersion, minorVersion, nil 1108} 1109