git-review: add submit command
- add submit command. Fixes golang/go#9297.
- detect GitHub clones during git mail. Fixes golang/go#9266.
- expand api.go to be able to make authenticated Gerrit API calls.
- remove subcommand shorthands, since people don't invoke git-review directly.
the original functionality would now be implemented as short git aliases.
- check for unstaged changes during sync + suggest stash.
(git pull -r is going to whine anyway, might as well give a nicer error.)
- add testing hooks to capture death, stdout, stderr.
- add fake gerrit server for testing.
- add mail tests.
- add sync tests.
- add submit tests.
- fix change test.
Change-Id: I92a0113dca255c4205890776e2ceec6a1211606a
Reviewed-on: https://go-review.googlesource.com/1518
Reviewed-by: Andrew Gerrand <adg@golang.org>
diff --git a/git-review/api.go b/git-review/api.go
index ea42f6c..d5a9a2e 100644
--- a/git-review/api.go
+++ b/git-review/api.go
@@ -5,66 +5,277 @@
package main
import (
- "bufio"
+ "bytes"
"encoding/json"
- "errors"
"fmt"
+ "io"
+ "io/ioutil"
"net/http"
- "net/url"
+ "os"
"strings"
)
-const (
- sourceHost = ".googlesource.com"
- reviewSuffix = "-review"
-)
+// auth holds cached data about authentication to Gerrit.
+var auth struct {
+ host string // "go.googlesource.com"
+ url string // "https://go-review.googlesource.com"
+ project string // "go", "tools", "crypto", etc
-var notFound = errors.New("not found")
+ // Authentication information.
+ // Either cookie name + value from git cookie file
+ // or username and password from .netrc.
+ cookieName string
+ cookieValue string
+ user string
+ password string
+}
-func getChange(origin, id string) (*Change, error) {
- u, err := url.Parse(origin)
+// loadGerritOrigin loads the Gerrit host name from the origin remote.
+// If the origin remote does not appear to be a Gerrit server
+// (is missing, is GitHub, is not https, has too many path elements),
+// loadGerritOrigin dies.
+func loadGerritOrigin() {
+ if auth.host != "" {
+ return
+ }
+
+ origin := getOutput("git", "config", "remote.origin.url")
+ if origin == "" {
+ dief("git config remote.origin.url: origin not found")
+ }
+ if strings.Contains(origin, "//github.com/") {
+ dief("git origin must be a Gerrit host, not GitHub: %s", origin)
+ }
+
+ if !strings.HasPrefix(origin, "https://") {
+ dief("git origin must be an https:// URL: %s", origin)
+ }
+ // https:// prefix and then one slash between host and top-level name
+ if strings.Count(origin, "/") != 3 {
+ dief("git origin is malformed: %s", origin)
+ }
+ host := origin[len("https://"):strings.LastIndex(origin, "/")]
+
+ // In the case of Google's Gerrit, host is go.googlesource.com
+ // and apiURL uses go-review.googlesource.com, but the Gerrit
+ // setup instructions do not write down a cookie explicitly for
+ // go-review.googlesource.com, so we look for the non-review
+ // host name instead.
+ url := origin
+ if i := strings.Index(url, ".googlesource.com"); i >= 0 {
+ url = url[:i] + "-review" + url[i:]
+ }
+ i := strings.LastIndex(url, "/")
+ url, project := url[:i], url[i+1:]
+
+ auth.host = host
+ auth.url = url
+ auth.project = project
+}
+
+// loadAuth loads the authentication tokens for making API calls to
+// the Gerrit origin host.
+func loadAuth() {
+ if auth.user != "" || auth.cookieName != "" {
+ return
+ }
+
+ loadGerritOrigin()
+
+ // First look in Git's http.cookiefile, which is where Gerrit
+ // now tells users to store this information.
+ if cookieFile := getOutput("git", "config", "http.cookiefile"); cookieFile != "" {
+ data, _ := ioutil.ReadFile(cookieFile)
+ for _, line := range strings.Split(string(data), "\n") {
+ f := strings.Split(line, "\t")
+ if len(f) >= 7 && f[0] == auth.host {
+ auth.cookieName = f[5]
+ auth.cookieValue = f[6]
+ return
+ }
+ }
+ }
+
+ // If not there, then look in $HOME/.netrc, which is where Gerrit
+ // used to tell users to store the information, until the passwords
+ // got so long that old versions of curl couldn't handle them.
+ data, _ := ioutil.ReadFile(os.Getenv("HOME") + "/.netrc")
+ for _, line := range strings.Split(string(data), "\n") {
+ if i := strings.Index(line, "#"); i >= 0 {
+ line = line[:i]
+ }
+ f := strings.Fields(line)
+ if len(f) >= 6 && f[0] == "machine" && f[1] == auth.host && f[2] == "login" && f[4] == "password" {
+ auth.user = f[3]
+ auth.password = f[5]
+ return
+ }
+ }
+
+ dief("cannot find authentication info for %s", auth.host)
+}
+
+// gerritError is an HTTP error response served by Gerrit.
+type gerritError struct {
+ url string
+ statusCode int
+ status string
+ body string
+}
+
+func (e *gerritError) Error() string {
+ if e.statusCode == http.StatusNotFound {
+ return "change not found on Gerrit server"
+ }
+
+ extra := strings.TrimSpace(e.body)
+ if extra != "" {
+ extra = ": " + extra
+ }
+ return fmt.Sprintf("%s%s", e.status, extra)
+}
+
+// gerritAPI executes a GET or POST request to a Gerrit API endpoint.
+// It uses GET when requestBody is nil, otherwise POST. If target != nil,
+// gerritAPI expects to get a 200 response with a body consisting of an
+// anti-xss line (]})' or some such) followed by JSON.
+// If requestBody != nil, gerritAPI sets the Content-Type to application/json.
+func gerritAPI(path string, requestBody []byte, target interface{}) error {
+ // Strictly speaking, we might be able to use unauthenticated
+ // access, by removing the /a/ from the URL, but that assumes
+ // that all the information we care about is publicly visible.
+ // Using authentication makes it possible for this to work with
+ // non-public CLs or Gerrit hosts too.
+ loadAuth()
+
+ if !strings.HasPrefix(path, "/") {
+ dief("internal error: gerritAPI called with malformed path")
+ }
+
+ url := auth.url + path
+ method := "GET"
+ var reader io.Reader
+ if requestBody != nil {
+ method = "POST"
+ reader = bytes.NewReader(requestBody)
+ }
+ req, err := http.NewRequest(method, url, reader)
if err != nil {
- return nil, fmt.Errorf("parsing origin URL: %v", err)
+ return err
}
- if !strings.HasSuffix(u.Host, sourceHost) {
- return nil, fmt.Errorf("origin URL not on %v", sourceHost)
+ if requestBody != nil {
+ req.Header.Set("Content-Type", "application/json")
}
- u.Host = strings.TrimSuffix(u.Host, sourceHost) + reviewSuffix + sourceHost
- u.Path = "/changes/" + id
- r, err := http.Get(u.String())
+ if auth.cookieName != "" {
+ req.AddCookie(&http.Cookie{
+ Name: auth.cookieName,
+ Value: auth.cookieValue,
+ })
+ } else {
+ req.SetBasicAuth(auth.user, auth.password)
+ }
+
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return err
+ }
+ body, err := ioutil.ReadAll(resp.Body)
+ resp.Body.Close()
+
+ if err != nil {
+ return fmt.Errorf("reading response body: %v", err)
+ }
+ if resp.StatusCode != http.StatusOK {
+ return &gerritError{url, resp.StatusCode, resp.Status, string(body)}
+ }
+
+ if target != nil {
+ i := bytes.IndexByte(body, '\n')
+ if i < 0 {
+ return fmt.Errorf("%s: malformed json response", url)
+ }
+ body = body[i:]
+ if err := json.Unmarshal(body, target); err != nil {
+ return fmt.Errorf("%s: malformed json response", url)
+ }
+ }
+ return nil
+}
+
+// fullChangeID returns the unambigous Gerrit change ID for the pending change on branch b.
+// The retruned ID has the form project~originbranch~Ihexhexhexhexhex.
+// See https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#change-id for details.
+func fullChangeID(b *Branch) string {
+ loadGerritOrigin()
+ return auth.project + "~" + strings.TrimPrefix(b.OriginBranch(), "origin/") + "~" + b.ChangeID()
+}
+
+// readGerritChange reads the metadata about a change from the Gerrit server.
+// The changeID should use the syntax project~originbranch~Ihexhexhexhexhex returned
+// by fullChangeID. Using only Ihexhexhexhexhex will work provided it uniquely identifies
+// a single change on the server.
+// The changeID can have additional query parameters appended to it, as in "normalid?o=LABELS".
+// See https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#change-id for details.
+func readGerritChange(changeID string) (*GerritChange, error) {
+ var c GerritChange
+ err := gerritAPI("/a/changes/"+changeID, nil, &c)
if err != nil {
return nil, err
}
- defer r.Body.Close()
- if r.StatusCode == http.StatusNotFound {
- return nil, notFound
- }
- if r.StatusCode != http.StatusOK {
- return nil, fmt.Errorf("unexpected status: %v", r.Status)
- }
- br := bufio.NewReader(r.Body)
- br.ReadSlice('\n') // throw away first line
- var c Change
- if err := json.NewDecoder(br).Decode(&c); err != nil {
- return nil, err
- }
- u.Path = fmt.Sprintf("/%v", c.Number)
- c.URL = u.String()
return &c, nil
}
-type Change struct {
- ChangeId string `json:"change_id"`
- Subject string
- Status string
- Number int `json:"_number"`
- Messages []*Message
- URL string
+// GerritChange is the JSON struct returned by a Gerrit CL query.
+type GerritChange struct {
+ ID string
+ Project string
+ Branch string
+ ChangeId string `json:"change_id"`
+ Subject string
+ Status string
+ Created string
+ Updated string
+ Mergeable bool
+ Insertions int
+ Deletions int
+ Number int `json:"_number"`
+ Owner *GerritAccount
+ Labels map[string]*GerritLabel
+ CurrentRevision string `json:"current_revision"`
+ Revisions map[string]*GerritRevision
+ Messages []*GerritMessage
}
-type Message struct {
+// GerritMessage is the JSON struct for a Gerrit MessageInfo.
+type GerritMessage struct {
Author struct {
Name string
}
Message string
}
+
+// GerritLabel is the JSON struct for a Gerrit LabelInfo.
+type GerritLabel struct {
+ Optional bool
+ Blocking bool
+ Approved *GerritAccount
+ Rejected *GerritAccount
+}
+
+// GerritAccount is the JSON struct for a Gerrit AccountInfo.
+type GerritAccount struct {
+ ID int `json:"_account_id"`
+}
+
+// GerritRevision is the JSON struct for a Gerrit RevisionInfo.
+type GerritRevision struct {
+ Number int `json:"_number"`
+ Ref string
+ Fetch map[string]*GerritFetch
+}
+
+// GerritFetch is the JSON struct for a Gerrit FetchInfo
+type GerritFetch struct {
+ URL string
+ Ref string
+}
diff --git a/git-review/branch.go b/git-review/branch.go
index 812d574..054191f 100644
--- a/git-review/branch.go
+++ b/git-review/branch.go
@@ -15,9 +15,10 @@
// Branch describes a Git branch.
type Branch struct {
- Name string // branch name
- changeID string // Change-Id of pending commit ("" if nothing pending)
- subject string // first line of pending commit ("" if nothing pending)
+ Name string // branch name
+ changeID string // Change-Id of pending commit ("" if nothing pending)
+ subject string // first line of pending commit ("" if nothing pending)
+ commitHash string // commit hash of pending commit ("" if nothing pending)
}
// CurrentBranch returns the current branch.
@@ -26,8 +27,11 @@
return &Branch{Name: name}
}
+// 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 {
- argv := []string{"git", "rev-parse", "--abbrev-ref", "@{u}"}
+ argv := []string{"git", "rev-parse", "--abbrev-ref", b.Name + "@{u}"}
out, err := exec.Command(argv[0], argv[1:]...).CombinedOutput()
if err == nil && len(out) > 0 {
return string(bytes.TrimSpace(out))
@@ -56,6 +60,7 @@
if b.HasPendingCommit() {
b.changeID = headChangeID(b.Name)
b.subject = commitSubject(b.Name)
+ b.commitHash = commitHash(b.Name)
}
}
return b.changeID
@@ -66,17 +71,22 @@
return b.subject
}
+func (b *Branch) CommitHash() string {
+ b.ChangeID() // page in commit hash
+ return b.commitHash
+}
+
func commitSubject(ref string) string {
- const f = "--format=format:%s"
- return getOutput("git", "log", "-n", "1", f, ref, "--")
+ return getOutput("git", "log", "-n", "1", "--format=format:%s", ref, "--")
+}
+
+func commitHash(ref string) string {
+ return getOutput("git", "log", "-n", "1", "--format=format:%H", ref, "--")
}
func headChangeID(branch string) string {
- const (
- p = "Change-Id: "
- f = "--format=format:%b"
- )
- for _, s := range getLines("git", "log", "-n", "1", f, branch, "--") {
+ const p = "Change-Id: "
+ for _, s := range getLines("git", "log", "-n", "1", "--format=format:%b", branch, "--") {
if strings.HasPrefix(s, p) {
return strings.TrimSpace(strings.TrimPrefix(s, p))
}
@@ -91,11 +101,22 @@
return len(getOutput("git", "log", "--grep", "Change-Id: "+id, b.OriginBranch())) > 0
}
-var stagedRe = regexp.MustCompile(`^[ACDMR] `)
+var stagedRE = regexp.MustCompile(`^[ACDMR] `)
func HasStagedChanges() bool {
for _, s := range getLines("git", "status", "-b", "--porcelain") {
- if stagedRe.MatchString(s) {
+ if stagedRE.MatchString(s) {
+ return true
+ }
+ }
+ return false
+}
+
+var unstagedRE = regexp.MustCompile(`^.[ACDMR] `)
+
+func HasUnstagedChanges() bool {
+ for _, s := range getLines("git", "status", "-b", "--porcelain") {
+ if unstagedRE.MatchString(s) {
return true
}
}
@@ -123,3 +144,12 @@
}
return branches
}
+
+// GerritChange returns the change metadata from the Gerrit server
+// for the branch's pending change.
+func (b *Branch) GerritChange() (*GerritChange, error) {
+ if !b.HasPendingCommit() {
+ return nil, fmt.Errorf("no pending commit")
+ }
+ return readGerritChange(fullChangeID(b) + "?o=LABELS&o=CURRENT_REVISION")
+}
diff --git a/git-review/change_test.go b/git-review/change_test.go
index 34c7a87..d055287 100644
--- a/git-review/change_test.go
+++ b/git-review/change_test.go
@@ -29,7 +29,7 @@
trun(t, gt.client, "git", "add", "file")
testMain(t, "change", "work")
testRan(t, "git checkout -q work",
- "git commit -q --allow-empty -m my commit msg")
+ "git commit -q --allow-empty -m foo: my commit msg")
t.Logf("master -> dev.branch")
testMain(t, "change", "dev.branch")
diff --git a/git-review/mail.go b/git-review/mail.go
index 93867d4..99821e3 100644
--- a/git-review/mail.go
+++ b/git-review/mail.go
@@ -45,7 +45,10 @@
"Use 'review change' to include them or 'review mail -f' to force it.")
}
- refSpec := "HEAD:refs/for/master"
+ // for side effect of dying with a good message if origin is GitHub
+ loadGerritOrigin()
+
+ refSpec := b.PushSpec()
start := "%"
if *rList != "" {
refSpec += mailList(start, "r", string(*rList))
@@ -70,6 +73,11 @@
run("git", "tag", "-f", b.Name+".mailed")
}
+// PushSpec returns the spec for a Gerrit push command to publish the change in b.
+func (b *Branch) PushSpec() string {
+ return "HEAD:refs/for/" + strings.TrimPrefix(b.OriginBranch(), "origin/")
+}
+
// mailAddressRE matches the mail addresses we admit. It's restrictive but admits
// all the addresses in the Go CONTRIBUTORS file at time of writing (tested separately).
var mailAddressRE = regexp.MustCompile(`^[a-zA-Z0-9][-_.a-zA-Z0-9]*@[-_.a-zA-Z0-9]+$`)
diff --git a/git-review/mail_test.go b/git-review/mail_test.go
new file mode 100644
index 0000000..8b80f9f
--- /dev/null
+++ b/git-review/mail_test.go
@@ -0,0 +1,37 @@
+// 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 "testing"
+
+func TestMail(t *testing.T) {
+ gt := newGitTest(t)
+ defer gt.done()
+ gt.work(t)
+
+ // fake auth information to avoid Gerrit error
+ auth.host = "gerrit.fake"
+ auth.user = "not-a-user"
+ defer func() {
+ auth.host = ""
+ auth.user = ""
+ }()
+
+ testMain(t, "mail")
+ testRan(t,
+ "git push -q origin HEAD:refs/for/master",
+ "git tag -f work.mailed")
+}
+
+func TestMailGitHub(t *testing.T) {
+ gt := newGitTest(t)
+ defer gt.done()
+ gt.work(t)
+
+ trun(t, gt.client, "git", "config", "remote.origin.url", "https://github.com/golang/go")
+
+ testMainDied(t, "mail")
+ testPrintedStderr(t, "git origin must be a Gerrit host, not GitHub: https://github.com/golang/go")
+}
diff --git a/git-review/review.go b/git-review/review.go
index 6a6672d..d06c928 100644
--- a/git-review/review.go
+++ b/git-review/review.go
@@ -19,6 +19,7 @@
"bytes"
"flag"
"fmt"
+ "io"
"os"
"os/exec"
"strconv"
@@ -82,6 +83,9 @@
Show local branches and their head commits.
If -r is specified, show additional information from Gerrit.
+ submit
+ Submit the completed change commit into the repository.
+
sync
Fetch changes from the remote repository and merge them into
the current branch, rebasing the change commit on top of them.
@@ -109,7 +113,7 @@
installHook()
switch command {
- case "change", "c":
+ case "change":
change(args)
case "gofmt":
dief("gofmt not implemented")
@@ -117,9 +121,11 @@
// done - installHook already ran
case "mail", "m":
mail(args)
- case "pending", "p":
+ case "pending":
pending(args)
- case "sync", "s":
+ case "submit":
+ submit(args)
+ case "sync":
doSync(args)
default:
flags.Usage()
@@ -145,7 +151,7 @@
}
}
-var runLog []string
+var runLogTrap []string
func runErr(command string, args ...string) error {
if *verbose > 0 || *noRun {
@@ -154,13 +160,19 @@
if *noRun {
return nil
}
- if runLog != nil {
- runLog = append(runLog, strings.TrimSpace(command+" "+strings.Join(args, " ")))
+ if runLogTrap != nil {
+ runLogTrap = append(runLogTrap, strings.TrimSpace(command+" "+strings.Join(args, " ")))
}
cmd := exec.Command(command, args...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
+ if stdoutTrap != nil {
+ cmd.Stdout = stdoutTrap
+ }
cmd.Stderr = os.Stderr
+ if stderrTrap != nil {
+ cmd.Stderr = stderrTrap
+ }
return cmd.Run()
}
@@ -215,8 +227,14 @@
}
}
+var stdoutTrap, stderrTrap *bytes.Buffer
+
func printf(format string, args ...interface{}) {
- fmt.Fprintf(os.Stderr, "%s: %s\n", os.Args[0], fmt.Sprintf(format, args...))
+ w := io.Writer(os.Stderr)
+ if stderrTrap != nil {
+ w = stderrTrap
+ }
+ fmt.Fprintf(w, "%s: %s\n", os.Args[0], fmt.Sprintf(format, args...))
}
// count is a flag.Value that is like a flag.Bool and a flag.Int.
diff --git a/git-review/submit.go b/git-review/submit.go
new file mode 100644
index 0000000..8db32ad
--- /dev/null
+++ b/git-review/submit.go
@@ -0,0 +1,142 @@
+// 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 (
+ "sort"
+ "time"
+)
+
+// TODO(rsc): Add -tbr.
+
+func submit(args []string) {
+ expectZeroArgs(args, "submit")
+
+ // Must have pending change, no staged changes.
+ b := CurrentBranch()
+ if !b.HasPendingCommit() {
+ dief("cannot submit: no pending commit")
+ }
+ checkStaged("submit")
+
+ // Also, no unstaged changes, at least for now.
+ // This makes sure the sync at the end will work well.
+ // We can relax this later if there is a good reason.
+ checkUnstaged("submit")
+
+ // Fetch Gerrit information about this change.
+ ch, err := b.GerritChange()
+ if err != nil {
+ dief("%v", err)
+ }
+
+ // Check Gerrit change status.
+ switch ch.Status {
+ default:
+ dief("cannot submit: unexpected Gerrit change status %q", ch.Status)
+
+ case "NEW", "SUBMITTED":
+ // Not yet "MERGED", so try the submit.
+ // "SUBMITTED" is a weird state. It means that Submit has been clicked once,
+ // but it hasn't happened yet, usually because of a merge failure.
+ // The user may have done git sync and may now have a mergable
+ // copy waiting to be uploaded, so continue on as if it were "NEW".
+
+ case "MERGED":
+ // Can happen if moving between different clients.
+ dief("cannot submit: change already submitted, run 'git sync'")
+
+ case "ABANDONED":
+ dief("cannot submit: change abandoned")
+ }
+
+ // Check for label approvals (like CodeReview+2).
+ // The final submit will check these too, but it is better to fail now.
+ var names []string
+ for name := range ch.Labels {
+ names = append(names, name)
+ }
+ sort.Strings(names)
+ for _, name := range names {
+ label := ch.Labels[name]
+ if label.Optional {
+ continue
+ }
+ if label.Rejected != nil {
+ dief("cannot submit: change has %s rejection", name)
+ }
+ if label.Approved == nil {
+ dief("cannot submit: change missing %s approval", name)
+ }
+ }
+
+ // Upload most recent revision if not already on server.
+ if b.CommitHash() != ch.CurrentRevision {
+ run("git", "push", "-q", "origin", b.PushSpec())
+
+ // Refetch change information, especially mergeable.
+ ch, err = b.GerritChange()
+ if err != nil {
+ dief("%v", err)
+ }
+ }
+
+ // Don't bother if the server can't merge the changes.
+ if !ch.Mergeable {
+ // Server cannot merge; explicit sync is needed.
+ dief("cannot submit: conflicting changes submitted, run 'git sync'")
+ }
+
+ // Otherwise, try the submit. Sends back updated GerritChange,
+ // but we need extended information and the reply is in the
+ // "SUBMITTED" state anyway, so ignore the GerritChange
+ // in the response and fetch a new one below.
+ ch = new(GerritChange)
+ if err := gerritAPI("/a/changes/"+fullChangeID(b)+"/submit", []byte(`{"wait_for_merge": true}`), nil); err != nil {
+ dief("cannot submit: %v", err)
+ }
+
+ // It is common to get back "SUBMITTED" for a split second after the
+ // request is made. That indicates that the change has been queued for submit,
+ // but the first merge (the one wait_for_merge waited for)
+ // failed, possibly due to a spurious condition. We see this often, and the
+ // status usually changes to MERGED shortly thereafter.
+ // Wait a little while to see if we can get to a different state.
+ const steps = 6
+ const max = 2 * time.Second
+ for i := 0; i < steps; i++ {
+ time.Sleep(max * (1 << uint(i+1)) / (1 << steps))
+ ch, err = b.GerritChange()
+ if err != nil {
+ dief("%v", err)
+ }
+ if ch.Status != "SUBMITTED" {
+ break
+ }
+ }
+
+ switch ch.Status {
+ default:
+ dief("cannot submit: unexpected post-submit Gerrit change status %q", ch.Status)
+
+ case "MERGED":
+ // good
+
+ case "SUBMITTED":
+ // see above
+ dief("cannot submit: timed out waiting for change to be submitted by Gerrit")
+ }
+
+ // Sync client to revision that Gerrit committed, but only if we can do it cleanly.
+ // Otherwise require user to run 'git sync' themselves (if they care).
+ run("git", "fetch", "-q")
+ if err := runErr("git", "checkout", "-q", "-B", b.Name, ch.CurrentRevision, "--"); err != nil {
+ dief("submit succeeded, but cannot sync local branch\n"+
+ "\trun 'git sync' to sync, or\n"+
+ "\trun 'git branch -D %s; git change master; git sync' to discard local branch", b.Name)
+ }
+
+ // Done! Change is submitted, branch is up to date, ready for new work.
+}
diff --git a/git-review/submit_test.go b/git-review/submit_test.go
new file mode 100644
index 0000000..c818e53
--- /dev/null
+++ b/git-review/submit_test.go
@@ -0,0 +1,166 @@
+// 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 (
+ "strings"
+ "testing"
+)
+
+func TestSubmitErrors(t *testing.T) {
+ gt := newGitTest(t)
+ defer gt.done()
+
+ srv := newGerritServer(t)
+ defer srv.done()
+
+ t.Logf("> no commit")
+ testMainDied(t, "submit")
+ testPrintedStderr(t, "cannot submit: no pending commit")
+ write(t, gt.client+"/file1", "")
+ trun(t, gt.client, "git", "add", "file1")
+ trun(t, gt.client, "git", "commit", "-m", "msg\n\nChange-Id: I123456789\n")
+
+ t.Logf("> staged changes")
+ write(t, gt.client+"/file1", "asdf")
+ trun(t, gt.client, "git", "add", "file1")
+ testMainDied(t, "submit")
+ testPrintedStderr(t, "cannot submit: staged changes exist",
+ "git status", "!git stash", "!git add", "git-review change")
+ testNoStdout(t)
+
+ t.Logf("> unstaged changes")
+ write(t, gt.client+"/file1", "actual content")
+ testMainDied(t, "submit")
+ testPrintedStderr(t, "cannot submit: unstaged changes exist",
+ "git status", "git stash", "git add", "git-review change")
+ testNoStdout(t)
+ testRan(t)
+ trun(t, gt.client, "git", "commit", "--amend", "--no-edit")
+
+ t.Logf("> not found")
+ testMainDied(t, "submit")
+ testPrintedStderr(t, "change not found on Gerrit server")
+
+ setJSON := func(json string) {
+ srv.setReply("/a/changes/proj~master~I123456789", gerritReply{body: ")]}'\n" + json})
+ }
+
+ t.Logf("> malformed json")
+ setJSON("XXX")
+ testMainDied(t, "submit")
+ testRan(t) // nothing
+ testPrintedStderr(t, "malformed json response")
+
+ t.Logf("> unexpected change status")
+ setJSON(`{"status": "UNEXPECTED"}`)
+ testMainDied(t, "submit")
+ testRan(t) // nothing
+ testPrintedStderr(t, "cannot submit: unexpected Gerrit change status \"UNEXPECTED\"")
+
+ t.Logf("> already merged")
+ setJSON(`{"status": "MERGED"}`)
+ testMainDied(t, "submit")
+ testRan(t) // nothing
+ testPrintedStderr(t, "cannot submit: change already submitted, run 'git sync'")
+
+ t.Logf("> abandoned")
+ setJSON(`{"status": "ABANDONED"}`)
+ testMainDied(t, "submit")
+ testRan(t) // nothing
+ testPrintedStderr(t, "cannot submit: change abandoned")
+
+ t.Logf("> missing approval")
+ setJSON(`{"status": "NEW", "labels": {"Code-Review": {}}}`)
+ testMainDied(t, "submit")
+ testRan(t) // nothing
+ testPrintedStderr(t, "cannot submit: change missing Code-Review approval")
+
+ t.Logf("> rejection")
+ setJSON(`{"status": "NEW", "labels": {"Code-Review": {"rejected": {}}}}`)
+ testMainDied(t, "submit")
+ testRan(t) // nothing
+ testPrintedStderr(t, "cannot submit: change has Code-Review rejection")
+
+ t.Logf("> unmergeable")
+ setJSON(`{"status": "NEW", "mergeable": false, "labels": {"Code-Review": {"approved": {}}}}`)
+ testMainDied(t, "submit")
+ testRan(t, "git push -q origin HEAD:refs/for/master")
+ testPrintedStderr(t, "cannot submit: conflicting changes submitted, run 'git sync'")
+
+ t.Logf("> submit with unexpected status")
+ const newJSON = `{"status": "NEW", "mergeable": true, "labels": {"Code-Review": {"approved": {}}}}`
+ setJSON(newJSON)
+ srv.setReply("/a/changes/proj~master~I123456789/submit", gerritReply{body: ")]}'\n" + newJSON})
+ testMainDied(t, "submit")
+ testRan(t, "git push -q origin HEAD:refs/for/master")
+ testPrintedStderr(t, "cannot submit: unexpected post-submit Gerrit change status \"NEW\"")
+}
+
+func TestSubmitTimeout(t *testing.T) {
+ gt := newGitTest(t)
+ defer gt.done()
+ srv := newGerritServer(t)
+ defer srv.done()
+
+ gt.work(t)
+
+ setJSON := func(json string) {
+ srv.setReply("/a/changes/proj~master~I123456789", gerritReply{body: ")]}'\n" + json})
+ }
+
+ t.Log("> submit with timeout")
+ const submittedJSON = `{"status": "SUBMITTED", "mergeable": true, "labels": {"Code-Review": {"approved": {}}}}`
+ setJSON(submittedJSON)
+ srv.setReply("/a/changes/proj~master~I123456789/submit", gerritReply{body: ")]}'\n" + submittedJSON})
+ testMainDied(t, "submit")
+ testRan(t, "git push -q origin HEAD:refs/for/master")
+ testPrintedStderr(t, "cannot submit: timed out waiting for change to be submitted by Gerrit")
+}
+
+func TestSubmit(t *testing.T) {
+ gt := newGitTest(t)
+ defer gt.done()
+ srv := newGerritServer(t)
+ defer srv.done()
+
+ gt.work(t)
+ trun(t, gt.client, "git", "tag", "-f", "work.mailed")
+ clientHead := strings.TrimSpace(trun(t, gt.client, "git", "log", "-n", "1", "--format=format:%H"))
+
+ write(t, gt.server+"/file", "another change")
+ trun(t, gt.server, "git", "add", "file")
+ trun(t, gt.server, "git", "commit", "-m", "conflict")
+ serverHead := strings.TrimSpace(trun(t, gt.server, "git", "log", "-n", "1", "--format=format:%H"))
+
+ t.Log("> submit")
+ var (
+ newJSON = `{"status": "NEW", "mergeable": true, "current_revision": "` + clientHead + `", "labels": {"Code-Review": {"approved": {}}}}`
+ submittedJSON = `{"status": "SUBMITTED", "mergeable": true, "current_revision": "` + clientHead + `", "labels": {"Code-Review": {"approved": {}}}}`
+ mergedJSON = `{"status": "MERGED", "mergeable": true, "current_revision": "` + serverHead + `", "labels": {"Code-Review": {"approved": {}}}}`
+ )
+ submitted := false
+ npoll := 0
+ srv.setReply("/a/changes/proj~master~I123456789", gerritReply{f: func() gerritReply {
+ if !submitted {
+ return gerritReply{body: ")]}'\n" + newJSON}
+ }
+ if npoll++; npoll <= 2 {
+ return gerritReply{body: ")]}'\n" + submittedJSON}
+ }
+ return gerritReply{body: ")]}'\n" + mergedJSON}
+ }})
+ srv.setReply("/a/changes/proj~master~I123456789/submit", gerritReply{f: func() gerritReply {
+ if submitted {
+ return gerritReply{status: 409}
+ }
+ submitted = true
+ return gerritReply{body: ")]}'\n" + submittedJSON}
+ }})
+ testMain(t, "submit")
+ testRan(t,
+ "git fetch -q",
+ "git checkout -q -B work "+serverHead+" --")
+}
diff --git a/git-review/sync.go b/git-review/sync.go
index c6618f9..5984df3 100644
--- a/git-review/sync.go
+++ b/git-review/sync.go
@@ -13,6 +13,11 @@
b := CurrentBranch()
id := b.ChangeID()
+ // Don't sync with staged or unstaged changes.
+ // rebase is going to complain if we don't, and we can give a nicer error.
+ checkStaged("sync")
+ checkUnstaged("sync")
+
// Pull remote changes into local branch.
// We do this in one command so that people following along with 'git sync -v'
// see fewer commands to understand.
@@ -28,3 +33,20 @@
run("git", "reset", "HEAD^")
}
}
+
+func checkStaged(cmd string) {
+ if HasStagedChanges() {
+ dief("cannot %s: staged changes exist\n"+
+ "\trun 'git status' to see changes\n"+
+ "\trun 'git-review change' to commit staged changes", cmd)
+ }
+}
+
+func checkUnstaged(cmd string) {
+ if HasUnstagedChanges() {
+ dief("cannot %s: unstaged changes exist\n"+
+ "\trun 'git status' to see changes\n"+
+ "\trun 'git stash' to save unstaged changes\n"+
+ "\trun 'git add' and 'git-review change' to commit staged changes", cmd)
+ }
+}
diff --git a/git-review/sync_test.go b/git-review/sync_test.go
new file mode 100644
index 0000000..64ad7c6
--- /dev/null
+++ b/git-review/sync_test.go
@@ -0,0 +1,48 @@
+// 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 "testing"
+
+func TestSync(t *testing.T) {
+ gt := newGitTest(t)
+ defer gt.done()
+
+ testMain(t, "change", "work")
+
+ // check for error with unstaged changes
+ write(t, gt.client+"/file1", "")
+ trun(t, gt.client, "git", "add", "file1")
+ write(t, gt.client+"/file1", "actual content")
+ testMainDied(t, "sync")
+ testPrintedStderr(t, "cannot sync: unstaged changes exist",
+ "git status", "git stash", "git add", "git-review change")
+ testNoStdout(t)
+
+ // check for error with staged changes
+ trun(t, gt.client, "git", "add", "file1")
+ testMainDied(t, "sync")
+ testPrintedStderr(t, "cannot sync: staged changes exist",
+ "git status", "!git stash", "!git add", "git-review change")
+ testNoStdout(t)
+
+ // check for success after stash
+ trun(t, gt.client, "git", "stash")
+ testMain(t, "sync")
+ testNoStdout(t)
+ testNoStderr(t)
+
+ // make server 1 step ahead of client
+ write(t, gt.server+"/file", "new content")
+ trun(t, gt.server, "git", "add", "file")
+ trun(t, gt.server, "git", "commit", "-m", "msg")
+
+ // check for success
+ testMain(t, "sync")
+ testNoStdout(t)
+ testNoStderr(t)
+}
+
+// TODO: Add TestSyncRebase?
diff --git a/git-review/util_test.go b/git-review/util_test.go
index 2834563..0c5c1e5 100644
--- a/git-review/util_test.go
+++ b/git-review/util_test.go
@@ -5,12 +5,17 @@
package main
import (
+ "bytes"
+ "fmt"
"io/ioutil"
+ "net"
+ "net/http"
"os"
"os/exec"
"path/filepath"
"reflect"
"strings"
+ "sync"
"testing"
)
@@ -26,6 +31,16 @@
os.Chdir(gt.pwd)
}
+func (gt *gitTest) work(t *testing.T) {
+ trun(t, gt.client, "git", "checkout", "-b", "work")
+ trun(t, gt.client, "git", "branch", "--set-upstream-to", "origin/master")
+
+ // make local change on client
+ write(t, gt.client+"/file", "new content")
+ trun(t, gt.client, "git", "add", "file")
+ trun(t, gt.client, "git", "commit", "-m", "msg\n\nChange-Id: I123456789\n")
+}
+
func newGitTest(t *testing.T) *gitTest {
tmpdir, err := ioutil.TempDir("", "git-review-test")
if err != nil {
@@ -82,40 +97,188 @@
}
}
-func trun(t *testing.T, dir string, cmdline ...string) {
+func trun(t *testing.T, dir string, cmdline ...string) string {
cmd := exec.Command(cmdline[0], cmdline[1:]...)
cmd.Dir = dir
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("in %s/, ran %s: %v\n%s", filepath.Base(dir), cmdline, err, out)
}
+ return string(out)
+}
+
+var (
+ runLog []string
+ stderr *bytes.Buffer
+ stdout *bytes.Buffer
+ died bool
+)
+
+var mainCanDie bool
+
+func testMainDied(t *testing.T, args ...string) {
+ mainCanDie = true
+ testMain(t, args...)
+ if !died {
+ t.Fatalf("expected to die, did not\nstdout:\n%sstderr:\n%s", stdout, stderr)
+ }
+}
+
+func testMainCanDie(t *testing.T, args ...string) {
+ mainCanDie = true
+ testMain(t, args...)
}
func testMain(t *testing.T, args ...string) {
t.Logf("git-review %s", strings.Join(args, " "))
- runLog = []string{} // non-nil, to trigger saving of commands
+
+ canDie := mainCanDie
+ mainCanDie = false // reset for next invocation
defer func() {
+ runLog = runLogTrap
+ stdout = stdoutTrap
+ stderr = stderrTrap
+
+ dieTrap = nil
+ runLogTrap = nil
+ stdoutTrap = nil
+ stderrTrap = nil
if err := recover(); err != nil {
- runLog = nil
- dieTrap = nil
- t.Fatalf("panic: %v", err)
+ if died && canDie {
+ return
+ }
+ var msg string
+ if died {
+ msg = "died"
+ } else {
+ msg = fmt.Sprintf("panic: %v", err)
+ }
+ t.Fatalf("%s\nstdout:\n%sstderr:\n%s", msg, stdout, stderr)
}
}()
dieTrap = func() {
+ died = true
panic("died")
}
+ died = false
+ runLogTrap = []string{} // non-nil, to trigger saving of commands
+ stdoutTrap = new(bytes.Buffer)
+ stderrTrap = new(bytes.Buffer)
os.Args = append([]string{"git-review"}, args...)
main()
-
- dieTrap = nil
}
func testRan(t *testing.T, cmds ...string) {
+ if cmds == nil {
+ cmds = []string{}
+ }
if !reflect.DeepEqual(runLog, cmds) {
t.Errorf("ran:\n%s", strings.Join(runLog, "\n"))
t.Errorf("wanted:\n%s", strings.Join(cmds, "\n"))
}
}
+
+func testPrinted(t *testing.T, buf *bytes.Buffer, name string, messages ...string) {
+ all := buf.String()
+ var errors bytes.Buffer
+ for _, msg := range messages {
+ if strings.HasPrefix(msg, "!") {
+ if strings.Contains(all, msg[1:]) {
+ fmt.Fprintf(&errors, "%s does (but should not) contain %q\n", name, msg[1:])
+ }
+ continue
+ }
+ if !strings.Contains(all, msg) {
+ fmt.Fprintf(&errors, "%s does not contain %q\n", name, msg)
+ }
+ }
+ if errors.Len() > 0 {
+ t.Fatalf("wrong output\n%s%s:\n%s", &errors, name, all)
+ }
+}
+
+func testPrintedStdout(t *testing.T, messages ...string) {
+ testPrinted(t, stdout, "stdout", messages...)
+}
+
+func testPrintedStderr(t *testing.T, messages ...string) {
+ testPrinted(t, stderr, "stderr", messages...)
+}
+
+func testNoStdout(t *testing.T) {
+ if stdout.Len() != 0 {
+ t.Fatalf("unexpected stdout:\n%s", stdout)
+ }
+}
+
+func testNoStderr(t *testing.T) {
+ if stderr.Len() != 0 {
+ t.Fatalf("unexpected stderr:\n%s", stderr)
+ }
+}
+
+type gerritServer struct {
+ l net.Listener
+ mu sync.Mutex
+ reply map[string]gerritReply
+}
+
+func newGerritServer(t *testing.T) *gerritServer {
+ l, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatalf("starting fake gerrit: %v", err)
+ }
+
+ auth.host = l.Addr().String()
+ auth.url = "http://" + auth.host
+ auth.project = "proj"
+ auth.user = "gopher"
+ auth.password = "PASSWORD"
+
+ s := &gerritServer{l: l, reply: make(map[string]gerritReply)}
+ go http.Serve(l, s)
+ return s
+}
+
+func (s *gerritServer) done() {
+ s.l.Close()
+ auth.host = ""
+ auth.url = ""
+ auth.project = ""
+ auth.user = ""
+ auth.password = ""
+}
+
+type gerritReply struct {
+ status int
+ body string
+ f func() gerritReply
+}
+
+func (s *gerritServer) setReply(path string, reply gerritReply) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.reply[path] = reply
+}
+
+func (s *gerritServer) ServeHTTP(w http.ResponseWriter, req *http.Request) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ reply, ok := s.reply[req.URL.Path]
+ if !ok {
+ http.NotFound(w, req)
+ return
+ }
+ if reply.f != nil {
+ reply = reply.f()
+ }
+ if reply.status != 0 {
+ w.WriteHeader(reply.status)
+ }
+ if len(reply.body) > 0 {
+ w.Write([]byte(reply.body))
+ }
+}