blob: 7c9b2820a1256e413e678c53680e60a6147220c7 [file] [log] [blame]
// Copyright 2014 The Go 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 (
"bytes"
"fmt"
"net/url"
"os"
"os/exec"
"regexp"
"runtime"
"strings"
)
// Branch describes a Git branch.
type Branch struct {
Name string // branch name
loadedPending bool // following fields are valid
originBranch string // upstream origin branch
commitsAhead int // number of commits ahead of origin branch
branchpoint string // latest commit hash shared with origin branch
pending []*Commit // pending commits, newest first (children before parents)
}
// A Commit describes a single pending commit on a Git branch.
type Commit struct {
Hash string // commit hash
ShortHash string // abbreviated commit hash
Parent string // parent hash
Merge string // for merges, hash of commit being merged into Parent
Message string // commit message
Subject string // first line of commit message
ChangeID string // Change-Id in commit message ("" if missing)
// For use by pending command.
g *GerritChange // associated Gerrit change data
gerr error // error loading Gerrit data
committed []string // list of files in this commit
}
// CurrentBranch returns the current branch.
func CurrentBranch() *Branch {
name := strings.TrimPrefix(trim(cmdOutput("git", "rev-parse", "--abbrev-ref", "HEAD")), "heads/")
return &Branch{Name: name}
}
// DetachedHead reports whether branch b corresponds to a detached HEAD
// (does not have a real branch name).
func (b *Branch) DetachedHead() bool {
return b.Name == "HEAD"
}
// OriginBranch returns the name of the origin branch that branch b tracks.
// The returned name is like "origin/master" or "origin/dev.garbage" or
// "origin/release-branch.go1.4".
func (b *Branch) OriginBranch() string {
if b.DetachedHead() {
// Detached head mode.
// "origin/HEAD" is clearly false, but it should be easy to find when it
// appears in other commands. Really any caller of OriginBranch
// should check for detached head mode.
return "origin/HEAD"
}
if b.originBranch != "" {
return b.originBranch
}
argv := []string{"git", "rev-parse", "--abbrev-ref", b.Name + "@{u}"}
cmd := exec.Command(argv[0], argv[1:]...)
if runtime.GOOS == "windows" {
// Workaround on windows. git for windows can't handle @{u} as same as
// given. Disable glob for this command if running on Cygwin or MSYS2.
envs := os.Environ()
envs = append(envs, "CYGWIN=noglob "+os.Getenv("CYGWIN"))
envs = append(envs, "MSYS=noglob "+os.Getenv("MSYS"))
cmd.Env = envs
}
setEnglishLocale(cmd)
out, err := cmd.CombinedOutput()
if err == nil && len(out) > 0 {
b.originBranch = string(bytes.TrimSpace(out))
return b.originBranch
}
// Have seen both "No upstream configured" and "no upstream configured".
if strings.Contains(string(out), "upstream configured") {
// Assume branch was created before we set upstream correctly.
// See if origin/main exists; if so, use it.
// Otherwise, fall back to origin/master.
argv := []string{"git", "rev-parse", "--abbrev-ref", "origin/main"}
cmd := exec.Command(argv[0], argv[1:]...)
setEnglishLocale(cmd)
if out, err := cmd.CombinedOutput(); err == nil {
b.originBranch = string(bytes.TrimSpace(out))
// Best effort attempt to correct setting for next time,
// and for "git status".
exec.Command("git", "branch", "-u", "origin/main").Run()
return b.originBranch
}
b.originBranch = "origin/master"
return b.originBranch
}
fmt.Fprintf(stderr(), "%v\n%s\n", commandString(argv[0], argv[1:]), out)
dief("%v", err)
panic("not reached")
}
func (b *Branch) FullName() string {
if b.Name != "HEAD" {
return "refs/heads/" + b.Name
}
return b.Name
}
// HasPendingCommit reports whether b has any pending commits.
func (b *Branch) HasPendingCommit() bool {
b.loadPending()
return b.commitsAhead > 0
}
// Pending returns b's pending commits, newest first (children before parents).
func (b *Branch) Pending() []*Commit {
b.loadPending()
return b.pending
}
// Branchpoint returns an identifier for the latest revision
// common to both this branch and its upstream branch.
func (b *Branch) Branchpoint() string {
b.loadPending()
return b.branchpoint
}
func (b *Branch) loadPending() {
if b.loadedPending {
return
}
b.loadedPending = true
// In case of early return.
b.branchpoint = trim(cmdOutput("git", "rev-parse", "HEAD"))
if b.DetachedHead() {
return
}
// Note: This runs in parallel with "git fetch -q",
// so the commands may see a stale version of origin/master.
// The use of origin here is for identifying what the branch has
// in common with origin (what's old on the branch).
// Any new commits in origin do not affect that.
// Note: --topo-order means child first, then parent.
origin := b.OriginBranch()
const numField = 5
all := trim(cmdOutput("git", "log", "--topo-order", "--format=format:%H%x00%h%x00%P%x00%B%x00%s%x00", origin+".."+b.FullName(), "--"))
fields := strings.Split(all, "\x00")
if len(fields) < numField {
return // nothing pending
}
for i, field := range fields {
fields[i] = strings.TrimLeft(field, "\r\n")
}
foundMergeBranchpoint := false
for i := 0; i+numField <= len(fields); i += numField {
c := &Commit{
Hash: fields[i],
ShortHash: fields[i+1],
Parent: strings.TrimSpace(fields[i+2]), // %P starts with \n for some reason
Message: fields[i+3],
Subject: fields[i+4],
}
if j := strings.Index(c.Parent, " "); j >= 0 {
c.Parent, c.Merge = c.Parent[:j], c.Parent[j+1:]
// Found merge point.
// Merges break the invariant that the last shared commit (the branchpoint)
// is the parent of the final commit in the log output.
// If c.Parent is on the origin branch, then since we are reading the log
// in (reverse) topological order, we know that c.Parent is the actual branchpoint,
// even if we later see additional commits on a different branch leading down to
// a lower location on the same origin branch.
// Check c.Merge (the second parent) too, so we don't depend on the parent order.
if strings.Contains(cmdOutput("git", "branch", "-a", "--contains", c.Parent), " "+origin+"\n") {
foundMergeBranchpoint = true
b.branchpoint = c.Parent
}
if strings.Contains(cmdOutput("git", "branch", "-a", "--contains", c.Merge), " "+origin+"\n") {
foundMergeBranchpoint = true
b.branchpoint = c.Merge
}
}
for _, line := range lines(c.Message) {
// Note: Keep going even if we find one, so that
// we take the last Change-Id line, just in case
// there is a commit message quoting another
// commit message.
// I'm not sure this can come up at all, but just in case.
if strings.HasPrefix(line, "Change-Id: ") {
c.ChangeID = line[len("Change-Id: "):]
}
}
b.pending = append(b.pending, c)
if !foundMergeBranchpoint {
b.branchpoint = c.Parent
}
}
b.commitsAhead = len(b.pending)
}
// CommitsBehind reports the number of commits present upstream
// that are not present in the current branch.
func (b *Branch) CommitsBehind() int {
return len(lines(cmdOutput("git", "log", "--format=format:x", b.FullName()+".."+b.OriginBranch(), "--")))
}
// Submitted reports whether some form of b's pending commit
// has been cherry picked to origin.
func (b *Branch) Submitted(id string) bool {
if id == "" {
return false
}
line := "Change-Id: " + id
out := cmdOutput("git", "log", "-n", "1", "-F", "--grep", line, b.Name+".."+b.OriginBranch(), "--")
return strings.Contains(out, line)
}
var stagedRE = regexp.MustCompile(`^[ACDMR] `)
// HasStagedChanges reports whether the working directory contains staged changes.
func HasStagedChanges() bool {
for _, s := range nonBlankLines(cmdOutput("git", "status", "-b", "--porcelain")) {
if stagedRE.MatchString(s) {
return true
}
}
return false
}
var unstagedRE = regexp.MustCompile(`^.[ACDMR]`)
// HasUnstagedChanges reports whether the working directory contains unstaged changes.
func HasUnstagedChanges() bool {
for _, s := range nonBlankLines(cmdOutput("git", "status", "-b", "--porcelain")) {
if unstagedRE.MatchString(s) {
return true
}
}
return false
}
// LocalChanges returns a list of files containing staged, unstaged, and untracked changes.
// The elements of the returned slices are typically file names, always relative to the root,
// but there are a few alternate forms. First, for renaming or copying, the element takes
// the form `from -> to`. Second, in the case of files with names that contain unusual characters,
// the files (or the from, to fields of a rename or copy) are quoted C strings.
// For now, we expect the caller only shows these to the user, so these exceptions are okay.
func LocalChanges() (staged, unstaged, untracked []string) {
for _, s := range lines(cmdOutput("git", "status", "-b", "--porcelain")) {
if len(s) < 4 || s[2] != ' ' {
continue
}
switch s[0] {
case 'A', 'C', 'D', 'M', 'R':
staged = append(staged, s[3:])
case '?':
untracked = append(untracked, s[3:])
}
switch s[1] {
case 'A', 'C', 'D', 'M', 'R':
unstaged = append(unstaged, s[3:])
}
}
return
}
// LocalBranches returns a list of all known local branches.
// If the current directory is in detached HEAD mode, one returned
// branch will have Name == "HEAD" and DetachedHead() == true.
func LocalBranches() []*Branch {
var branches []*Branch
current := CurrentBranch()
for _, s := range nonBlankLines(cmdOutput("git", "branch", "-q")) {
s = strings.TrimSpace(s)
if strings.HasPrefix(s, "* ") {
// * marks current branch in output.
// Normally the current branch has a name like any other,
// but in detached HEAD mode the branch listing shows
// a localized (translated) textual description instead of
// a branch name. Avoid language-specific differences
// by using CurrentBranch().Name for the current branch.
// It detects detached HEAD mode in a more portable way.
// (git rev-parse --abbrev-ref HEAD returns 'HEAD').
s = current.Name
}
branches = append(branches, &Branch{Name: s})
}
return branches
}
func OriginBranches() []string {
var branches []string
for _, line := range nonBlankLines(cmdOutput("git", "branch", "-a", "-q")) {
line = strings.TrimSpace(line)
if i := strings.Index(line, " -> "); i >= 0 {
line = line[:i]
}
name := strings.TrimSpace(strings.TrimPrefix(line, "* "))
if strings.HasPrefix(name, "remotes/origin/") {
branches = append(branches, strings.TrimPrefix(name, "remotes/"))
}
}
return branches
}
// GerritChange returns the change metadata from the Gerrit server
// for the branch's pending change.
// The extra strings are passed to the Gerrit API request as o= parameters,
// to enable additional information. Typical values include "LABELS" and "CURRENT_REVISION".
// See https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html for details.
func (b *Branch) GerritChange(c *Commit, extra ...string) (*GerritChange, error) {
if !b.HasPendingCommit() {
return nil, fmt.Errorf("no changes pending")
}
if c.ChangeID == "" {
return nil, fmt.Errorf("missing Change-Id")
}
id := fullChangeID(b, c)
for i, x := range extra {
if i == 0 {
id += "?"
} else {
id += "&"
}
id += "o=" + x
}
return readGerritChange(id)
}
// GerritChange returns the change metadata from the Gerrit server
// for the given changes, which each be be the result of fullChangeID(b, c) for some c.
// The extra strings are passed to the Gerrit API request as o= parameters,
// to enable additional information. Typical values include "LABELS" and "CURRENT_REVISION".
// See https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html for details.
func (b *Branch) GerritChanges(ids []string, extra ...string) ([][]*GerritChange, error) {
q := ""
for _, id := range ids {
if q != "" {
q += "&"
}
if strings.HasSuffix(id, "~") {
// result of fullChangeID(b, c) with missing Change-Id; don't send
q += "q=is:closed+is:open" // cannot match anything
continue
}
q += "q=change:" + url.QueryEscape(id)
}
if q == "" {
return nil, fmt.Errorf("no changes found")
}
for _, x := range extra {
q += "&o=" + url.QueryEscape(x)
}
return readGerritChanges(q)
}
// CommitByRev finds a unique pending commit by its git <rev>.
// It dies if rev cannot be resolved to a commit or that commit is not
// pending on b using the action ("mail", "submit") in the failure message.
func (b *Branch) CommitByRev(action, rev string) *Commit {
// Parse rev to a commit hash.
hash, err := cmdOutputErr("git", "rev-parse", "--verify", rev+"^{commit}")
if err != nil {
msg := strings.TrimPrefix(trim(err.Error()), "fatal: ")
dief("cannot %s: %s", action, msg)
}
hash = trim(hash)
// Check that hash is a pending commit.
var c *Commit
for _, c1 := range b.Pending() {
if c1.Hash == hash {
c = c1
break
}
}
if c == nil {
dief("cannot %s: commit hash %q not found in the current branch", action, hash)
}
return c
}
// DefaultCommit returns the default pending commit for this branch.
// It dies if there is not exactly one pending commit,
// using the action (e.g. "mail", "submit") and optional extra instructions
// in the failure message.
func (b *Branch) DefaultCommit(action, extra string) *Commit {
work := b.Pending()
if len(work) == 0 {
dief("cannot %s: no changes pending", action)
}
if len(work) >= 2 {
var buf bytes.Buffer
for _, c := range work {
fmt.Fprintf(&buf, "\n\t%s %s", c.ShortHash, c.Subject)
}
if extra != "" {
extra = "; " + extra
}
dief("cannot %s: multiple changes pending%s:%s", action, extra, buf.String())
}
return work[0]
}
// ListFiles returns the list of files in a given commit.
func ListFiles(c *Commit) []string {
return nonBlankLines(cmdOutput("git", "diff", "--name-only", c.Parent, c.Hash, "--"))
}
func cmdBranchpoint(args []string) {
expectZeroArgs(args, "sync")
fmt.Fprintf(stdout(), "%s\n", CurrentBranch().Branchpoint())
}
func cmdRebaseWork(args []string) {
expectZeroArgs(args, "rebase-work")
b := CurrentBranch()
if HasStagedChanges() || HasUnstagedChanges() {
dief("cannot rebase with uncommitted work")
}
if len(b.Pending()) == 0 {
dief("no pending work")
}
run("git", "rebase", "-i", b.Branchpoint())
}