review: overhaul of tool user interface

The 'create' and 'commit' commands are combined to become 'change',
which also handles switching branches.

Also, move the various commands to their own source files.

Change-Id: Ia9a1a5d27bdebdb2fad057e9069855c935cbcb21
Reviewed-on: https://go-review.googlesource.com/1100
Reviewed-by: Russ Cox <rsc@golang.org>
diff --git a/branch.go b/branch.go
new file mode 100644
index 0000000..628ee96
--- /dev/null
+++ b/branch.go
@@ -0,0 +1,84 @@
+// 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 (
+	"regexp"
+	"strings"
+)
+
+// 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)
+}
+
+// Submitted reports whether some form of b's pending commit
+// has been cherry picked to master.
+func (b *Branch) Submitted() bool {
+	return b.ChangeID != "" && changeSubmitted(b.ChangeID)
+}
+
+func CurrentBranch() *Branch {
+	b := &Branch{Name: currentBranchName()}
+	if hasPendingCommit(b.Name) {
+		b.ChangeID = headChangeID(b.Name)
+		b.Subject = commitSubject(b.Name)
+	}
+	return b
+}
+
+func hasPendingCommit(branch string) bool {
+	head := getOutput("git", "rev-parse", branch)
+	base := getOutput("git", "merge-base", "origin/master", branch)
+	return base != head
+}
+
+func currentBranchName() string {
+	return getOutput("git", "rev-parse", "--abbrev-ref", "HEAD")
+}
+
+func commitSubject(ref string) string {
+	const f = "--format=format:%s"
+	return getOutput("git", "log", "-n", "1", f, ref, "--")
+}
+
+func changeSubmitted(id string) bool {
+	s := "Change-Id: " + id
+	return len(getOutput("git", "log", "--grep", s, "origin/master")) > 0
+}
+
+func headChangeID(branch string) string {
+	const (
+		p = "Change-Id: "
+		f = "--format=format:%b"
+	)
+	for _, s := range getLines("git", "log", "-n", "1", f, branch, "--") {
+		if strings.HasPrefix(s, p) {
+			return strings.TrimSpace(strings.TrimPrefix(s, p))
+		}
+	}
+	dief("no Change-Id line found in HEAD commit on branch %s.", branch)
+	panic("unreachable")
+}
+
+var stagedRe = regexp.MustCompile(`^[ACDMR]  `)
+
+func hasStagedChanges() bool {
+	for _, s := range getLines("git", "status", "-b", "--porcelain") {
+		if stagedRe.MatchString(s) {
+			return true
+		}
+	}
+	return false
+}
+
+func localBranches() (branches []string) {
+	for _, s := range getLines("git", "branch", "-l", "-q") {
+		branches = append(branches, strings.TrimPrefix(s, "* "))
+	}
+	return branches
+}
diff --git a/change.go b/change.go
new file mode 100644
index 0000000..b4198ab
--- /dev/null
+++ b/change.go
@@ -0,0 +1,77 @@
+// 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 (
+	"fmt"
+	"os"
+)
+
+func change(args []string) {
+	if len(args) > 1 {
+		fmt.Fprintf(os.Stderr, "Usage: review [-n] [-v] change [branch]\n")
+		os.Exit(2)
+
+	}
+
+	// Checkout or create branch, if specified.
+	checkedOut := false
+	if len(args) == 1 {
+		checkoutOrCreate(args[0])
+		checkedOut = true
+	}
+
+	// Create or amend change commit.
+	branch := CurrentBranch()
+	if branch.Name == "master" {
+		if checkedOut {
+			// Permit "review change master".
+			return
+		}
+		dief("can't commit to master branch (use 'review change branchname').")
+	}
+	if branch.ChangeID == "" {
+		// No change commit on this branch, create one.
+		commitChanges(false)
+		return
+	}
+	if checkedOut {
+		// If we switched to an existing branch, don't amend the
+		// commit. (The user can run 'review change' to do that.)
+		return
+	}
+	// Amend the commit.
+	commitChanges(true)
+}
+
+func commitChanges(amend bool) {
+	if !hasStagedChanges() {
+		printf("no staged changes. Did you forget to 'git add'?")
+	}
+	args := []string{"commit", "-q", "--allow-empty"}
+	if amend {
+		args = append(args, "--amend")
+	}
+	run("git", args...)
+	printf("change updated.")
+}
+
+func checkoutOrCreate(branch string) {
+	// If branch exists, check it out.
+	for _, b := range localBranches() {
+		if b == branch {
+			run("git", "checkout", "-q", branch)
+			printf("changed to branch %v.", branch)
+			return
+		}
+	}
+
+	// If it doesn't exist, create a new branch.
+	if currentBranchName() != "master" {
+		dief("can't create a new branch from non-master branch.")
+	}
+	run("git", "checkout", "-q", "-b", branch)
+	printf("changed to new branch %v.", branch)
+}
diff --git a/hook.go b/hook.go
index c7865a2..baf0af1 100644
--- a/hook.go
+++ b/hook.go
@@ -4,6 +4,47 @@
 
 package main
 
+import (
+	"io/ioutil"
+	"os"
+	"path/filepath"
+)
+
+var hookFile = filepath.FromSlash(".git/hooks/commit-msg")
+
+func installHook() {
+	filename := filepath.Join(repoRoot(), hookFile)
+	_, err := os.Stat(filename)
+	if err == nil {
+		return
+	}
+	if !os.IsNotExist(err) {
+		dief("error checking for hook file: %v", err)
+	}
+	verbosef("Presubmit hook to add Change-Id to commit messages is missing.")
+	verbosef("Automatically creating it at %v.", filename)
+	hookContent := []byte(commitMsgHook)
+	if err := ioutil.WriteFile(filename, hookContent, 0700); err != nil {
+		dief("error writing hook file: %v", err)
+	}
+}
+
+func repoRoot() string {
+	dir, err := os.Getwd()
+	if err != nil {
+		dief("could not get current directory: %v", err)
+	}
+	for {
+		if _, err := os.Stat(filepath.Join(dir, ".git")); err == nil {
+			return dir
+		}
+		if len(dir) == 1 && dir[0] == filepath.Separator {
+			dief("git root not found. Rerun from within the Git tree.")
+		}
+		dir = filepath.Dir(dir)
+	}
+}
+
 var commitMsgHook = `#!/bin/sh
 # From Gerrit Code Review 2.2.1
 #
diff --git a/pending.go b/pending.go
new file mode 100644
index 0000000..287a0ab
--- /dev/null
+++ b/pending.go
@@ -0,0 +1,33 @@
+// 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 (
+	"flag"
+	"fmt"
+)
+
+func pending(args []string) {
+	if len(args) != 0 {
+		flag.Usage()
+	}
+	// TODO(adg): implement -r
+
+	current := currentBranchName()
+	for _, branch := range localBranches() {
+		p := "  "
+		if branch == current {
+			p = "* "
+		}
+		pending := hasPendingCommit(branch)
+		if branch == current || pending {
+			sub := "(no pending change)"
+			if pending {
+				sub = commitSubject(branch)
+			}
+			fmt.Printf("%v%v: %v\n", p, branch, sub)
+		}
+	}
+}
diff --git a/revert.go b/revert.go
new file mode 100644
index 0000000..286740e
--- /dev/null
+++ b/revert.go
@@ -0,0 +1,27 @@
+// 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 (
+	"fmt"
+	"os"
+)
+
+func revert(args []string) {
+	if len(args) == 0 {
+		fmt.Fprintf(os.Stderr, "Usage: %s [-n] [-v] revert files...\n", os.Args[0])
+		os.Exit(2)
+	}
+	branch := CurrentBranch()
+	if branch.Name == "master" {
+		dief("on master branch; can't revert.")
+	}
+	if branch.ChangeID == "" {
+		dief("no pending change; can't revert.")
+	}
+	// TODO(adg): make this work correctly before hooking it up
+	run("git", append([]string{"checkout", "HEAD^"}, args...)...)
+	run("git", append([]string{"add"}, args...)...)
+}
diff --git a/review.go b/review.go
index acce853..cf30dab 100644
--- a/review.go
+++ b/review.go
@@ -2,35 +2,33 @@
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 
-// TODO(adg): accept -a flag on 'create' and 'commit' (like git commit -a)
-// TODO(adg): accept -r flag on 'upload' to nominate reviewer
-// TODO(adg): support 'create' from non-master branches
+// TODO(adg): change command-line parsing so this works as a git alias
+// TODO(adg): rename 'upload' to 'mail'
+// TODO(adg): recognize non-master remote branches
+// TODO(adg): accept -a flag on 'commit' (like git commit -a)
 // TODO(adg): check style of commit message
-// TODO(adg): write doc comment
 // TOOD(adg): print gerrit votes on 'pending'
 // TODO(adg): add gofmt commit hook
-// TODO(adg): 'upload' warn about uncommitted changes (maybe commit/create too?)
-// TODO(adg): ability to edit commit message
 // TODO(adg): print changed files on review sync
+// TODO(adg): translate email addresses without @ by looking up somewhere
 
+// Command review provides a simple command-line user interface for
+// working with git repositories and the Gerrit code review system.
+// See "review help" for details.
 package main // import "golang.org/x/review"
 
 import (
+	"bytes"
 	"flag"
 	"fmt"
-	"io/ioutil"
 	"os"
 	"os/exec"
-	"path/filepath"
-	"regexp"
 	"strings"
-	"sync"
 )
 
 var (
-	hookFile = filepath.FromSlash(".git/hooks/commit-msg")
-	verbose  = flag.Bool("v", false, "verbose output")
-	noRun    = flag.Bool("n", false, "print but do not run commands")
+	verbose = flag.Bool("v", false, "verbose output")
+	noRun   = flag.Bool("n", false, "print but do not run commands")
 )
 
 const usage = `Usage: %s [-n] [-v] <command>
@@ -44,355 +42,71 @@
 
 Available comands:
 
-	create <name>
-		Create a local branch with the provided name
-		and commit the staged changes to it.
+	change [name]
+		Create a change commit, or amend an existing change commit,
+		with the staged changes. If a branch name is provided, check
+		out that branch (creating it if it does not exist).
+		(Does not amend the existing commit when switching branches.)
 
-	commit
-		Amend local branch HEAD commit with the staged changes.
+	pending [-r]
+		Show local branches and their head commits.
+		If -r is specified, show additional information from Gerrit.
 
-	diff
-		View differences between remote branch HEAD and
-		the local branch HEAD.
-		(The differences introduced by this change.)
+	upload [-f] [-r reviewer,...] [-cc mail,...]
+		Upload change commit to the code review server.
+		If -f is specified, upload even if there are staged changes.
 
-	upload [-r reviewer,...] [-cc mail,...]
-		Upload HEAD commit to the code review server.
+	upload -diff
+		Show the changes but do not upload.
 
 	sync
-		Fetch changes from the remote repository and merge them to the
-		current branch, rebasing the HEAD commit (if any) on top of
-		them. If the HEAD commit has been submitted, switch back to the
-		master branch and delete the feature branch.
+		Fetch changes from the remote repository and merge them into
+		the current branch, rebasing the change commit on top of them.
 
-	pending
-		Show local branches and their head commits.
+	revert files...
+		Revert the specified files to their state before the change
+		commit. (Be careful! This will discard your changes!)
+
+	gofmt
+		TBD
 
 `
 
 func main() {
 	flag.Usage = func() {
 		fmt.Fprintf(os.Stderr, usage, os.Args[0], os.Args[0])
-		os.Exit(2)
 	}
 	flag.Parse()
 
-	goToRepoRoot()
 	installHook()
 
-	command, args := flag.Arg(0), flag.Args()[1:]
+	args := flag.Args()
+	if len(args) == 0 {
+		flag.Usage()
+		os.Exit(2)
+	}
+	command, args := args[0], args[1:]
 
 	switch command {
 	case "help":
 		fmt.Fprintf(os.Stdout, help, os.Args[0])
-	case "create", "cr":
-		create(args)
-	case "commit", "co":
-		commit(args)
-	case "diff", "d":
-		diff(args)
-	case "upload", "u":
+	case "change", "c":
+		change(args)
+	case "pending", "p":
+		pending(args)
+	case "upload", "up", "u":
 		upload(args)
 	case "sync", "s":
 		doSync(args)
-	case "pending", "p":
-		pending(args)
+	case "revert":
+		dief("revert not implemented")
+	case "gofmt":
+		dief("gofmt not implemented")
 	default:
 		flag.Usage()
 	}
 }
 
-func create(args []string) {
-	if len(args) != 1 || args[0] == "" {
-		flag.Usage()
-	}
-	name := args[0]
-	if !hasStagedChanges() {
-		dief("no staged changes.\nDid you forget to 'git add'?")
-	}
-	if b := currentBranch(); b != "master" {
-		dief("must run 'create' from the master branch (now on %q).\n"+
-			"(Try 'review sync' or 'git checkout master' first.)",
-			b)
-	}
-	run("git", "checkout", "-q", "-b", name)
-	if err := runErr("git", "commit", "-q"); err != nil {
-		verbosef("Commit failed: %v\nSwitching back to master.\n", err)
-		run("git", "checkout", "-q", "master")
-		run("git", "branch", "-q", "-d", name)
-	}
-}
-
-func commit(args []string) {
-	if len(args) != 0 {
-		flag.Usage()
-	}
-	if !hasStagedChanges() {
-		dief("no staged changes. Did you forget to 'git add'?")
-	}
-	if currentBranch() == "master" {
-		dief("can't commit to master branch.")
-	}
-	run("git", "commit", "-q", "--amend", "-C", "HEAD")
-}
-
-func diff(args []string) {
-	if len(args) != 0 {
-		flag.Usage()
-	}
-	run("git", "diff", "HEAD^", "HEAD")
-}
-
-func upload(args []string) {
-	flags := flag.FlagSet{
-		Usage: func() {
-			fmt.Fprintf(os.Stderr, "Usage: review [-n] [-v] upload [-r reviewer,...] [-cc mail,...]\n")
-			os.Exit(2)
-		},
-	}
-	rList := flags.String("r", "", "comma-separated list of reviewers")
-	ccList := flags.String("cc", "", "comma-separated list of people to CC:")
-	if flags.Parse(args) != nil || len(flags.Args()) != 0 {
-		flags.Usage()
-	}
-	if currentBranch() == "master" {
-		dief("can't upload from master branch.")
-	}
-	refSpec := "HEAD:refs/for/master"
-	start := "%"
-	if *rList != "" {
-		refSpec += mailList(start, "r", *rList)
-		start = ","
-	}
-	if *ccList != "" {
-		refSpec += mailList(start, "cc", *ccList)
-	}
-	run("git", "push", "-q", "origin", refSpec)
-}
-
-// 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]+$`)
-
-// mailList turns the list of mail addresses from the flag value into the format
-// expected by gerrit. The start argument is a % or , depending on where we
-// are in the processing sequence.
-func mailList(start, tag string, flagList string) string {
-	spec := start
-	for i, addr := range strings.Split(flagList, ",") {
-		if !mailAddressRE.MatchString(addr) {
-			dief("%q is not a valid reviewer mail address", addr)
-		}
-		if i > 0 {
-			spec += ","
-		}
-		spec += tag + "=" + addr
-	}
-	return spec
-}
-
-func doSync(args []string) {
-	if len(args) != 0 {
-		flag.Usage()
-	}
-	run("git", "fetch", "-q")
-
-	// If we're on master, just fast-forward.
-	branch := currentBranch()
-	if branch == "master" {
-		run("git", "merge", "-q", "--ff-only", "origin/master")
-		return
-	}
-
-	// Check that exactly this commit was submitted to master. If so,
-	// switch back to master, fast-forward, delete the feature branch.
-	if branchContains("origin/master", "HEAD") {
-		run("git", "checkout", "-q", "master")
-		run("git", "merge", "-q", "--ff-only", "origin/master")
-		run("git", "branch", "-q", "-d", branch)
-		fmt.Printf("Change on %q submitted; branch deleted.\n"+
-			"Now on master branch.\n", branch)
-		return
-	}
-
-	// Check whether a rebased version of this commit was submitted to
-	// master. If so, switch back to master, fast-forward, and
-	// provide instructions for deleting the feature branch.
-	// (We're not 100% sure that the feature branch HEAD was submitted,
-	// so be cautious.)
-	if headSubmitted(branch) {
-		run("git", "checkout", "-q", "master")
-		run("git", "merge", "-q", "--ff-only", "origin/master")
-		fmt.Fprintf(os.Stderr,
-			"I think the change on %q has been submitted.\n"+
-				"If you agree, and no longer need branch %q, "+
-				"run:\n\tgit branch -D %v\n"+
-				"\nNow on master branch.\n",
-			branch, branch, branch)
-		return
-	}
-
-	// Bump master HEAD to that of origin/master, just in case the user
-	// switches back to master with "git checkout master" later.
-	// TODO(adg): maybe we shouldn't do this at all?
-	if !branchContains("origin/master", "master") {
-		run("git", "branch", "-f", "master", "origin/master")
-	}
-
-	// We have un-submitted changes on this feature branch; rebase.
-	run("git", "rebase", "-q", "origin/master")
-}
-
-func pending(args []string) {
-	if len(args) != 0 {
-		flag.Usage()
-	}
-	var (
-		wg      sync.WaitGroup
-		origin  = originURL()
-		current = currentBranch()
-	)
-	if current == "master" {
-		fmt.Println("Now on master branch.")
-	}
-	for _, branch := range localBranches() {
-		if branch == "master" {
-			continue
-		}
-		wg.Add(1)
-		go func(branch string) {
-			defer wg.Done()
-			p := ""
-			if branch == current {
-				p = "* "
-			}
-			id := headChangeId(branch)
-			c, err := getChange(origin, id)
-			switch err {
-			case notFound:
-				// TODO(adg): read local commit msg
-				var msg string
-				fmt.Printf("%v%v:\n\t%v\n\t(not uploaded)\n",
-					p, branch, msg)
-			case nil:
-				status := ""
-				switch c.Status {
-				case "MERGED":
-					status += " [submitted]"
-				case "ABANDONED":
-					status += " [abandoned]"
-				}
-				fmt.Printf("%v%v%v:\n\t%v\n\t%v\n",
-					p, branch, status, c.Subject, c.URL)
-			default:
-				fmt.Fprintf(os.Stderr, "fetching change for %q: %v\n", branch, err)
-			}
-		}(branch)
-	}
-	wg.Wait()
-}
-
-func originURL() string {
-	out, err := exec.Command("git", "config", "remote.origin.url").CombinedOutput()
-	if err != nil {
-		dief("could not find URL for 'origin' remote.\n"+
-			"Did you check out from the right place?\n"+
-			"git config remote.origin.url: %v\n"+
-			"%s", err, out)
-	}
-	return string(out)
-}
-
-func localBranches() (branches []string) {
-	for _, s := range getLines("git", "branch", "-l", "-q") {
-		branches = append(branches, strings.TrimPrefix(s, "* "))
-	}
-	return branches
-}
-
-func branchContains(branch, rev string) bool {
-	for _, s := range getLines("git", "branch", "-r", "--contains", rev) {
-		if s == branch {
-			return true
-		}
-	}
-	return false
-}
-
-var stagedRe = regexp.MustCompile(`^[ACDMR]  `)
-
-func hasStagedChanges() bool {
-	for _, s := range getLines("git", "status", "-b", "--porcelain") {
-		if stagedRe.MatchString(s) {
-			return true
-		}
-	}
-	return false
-}
-
-func currentBranch() string {
-	return strings.TrimSpace(getOutput("git", "rev-parse", "--abbrev-ref", "HEAD"))
-}
-
-func headSubmitted(branch string) bool {
-	s := "Change-Id: " + headChangeId(branch)
-	return len(getOutput("git", "log", "--grep", s, "origin/master")) > 0
-}
-
-func headChangeId(branch string) string {
-	const (
-		p = "Change-Id: "
-		f = "--format=format:%b"
-	)
-	for _, s := range getLines("git", "log", "-n", "1", f, branch, "--") {
-		if strings.HasPrefix(s, p) {
-			return strings.TrimSpace(strings.TrimPrefix(s, p))
-		}
-	}
-	dief("no Change-Id line found in HEAD commit on branch %s.", branch)
-	panic("unreachable")
-}
-
-func goToRepoRoot() {
-	prevDir, err := os.Getwd()
-	if err != nil {
-		dief("could not get current directory: %v", err)
-	}
-	for {
-		if _, err := os.Stat(".git"); err == nil {
-			return
-		}
-		if err := os.Chdir(".."); err != nil {
-			dief("could not chdir: %v", err)
-		}
-		currentDir, err := os.Getwd()
-		if err != nil {
-			dief("could not get current directory: %v", err)
-		}
-		if currentDir == prevDir {
-			dief("git root not found.\n" +
-				"Run from within the Git tree please.")
-		}
-		prevDir = currentDir
-	}
-}
-
-func installHook() {
-	_, err := os.Stat(hookFile)
-	if err == nil {
-		return
-	}
-	if !os.IsNotExist(err) {
-		dief("error checking for hook file: %v", err)
-	}
-	verbosef("Presubmit hook to add Change-Id to commit messages is missing.\n"+
-		"Automatically creating it at %v.\n", hookFile)
-	hookContent := []byte(commitMsgHook)
-	if err := ioutil.WriteFile(hookFile, hookContent, 0700); err != nil {
-		dief("error writing hook file: %v", err)
-	}
-}
-
 func run(command string, args ...string) {
 	if err := runErr(command, args...); err != nil {
 		if !*verbose {
@@ -423,12 +137,15 @@
 // NOTE: It should only be used to run commands that return information,
 // **not** commands that make any actual changes.
 func getOutput(command string, args ...string) string {
+	if *verbose {
+		fmt.Fprintln(os.Stderr, commandString(command, args))
+	}
 	b, err := exec.Command(command, args...).CombinedOutput()
 	if err != nil {
 		fmt.Fprintf(os.Stderr, "%v\n%s\n", commandString(command, args), b)
 		dief("%v", err)
 	}
-	return string(b)
+	return string(bytes.TrimSpace(b))
 }
 
 // getLines is like getOutput but it returns non-empty output lines.
@@ -437,10 +154,7 @@
 func getLines(command string, args ...string) []string {
 	var s []string
 	for _, l := range strings.Split(getOutput(command, args...), "\n") {
-		l = strings.TrimSpace(l)
-		if l != "" {
-			s = append(s, l)
-		}
+		s = append(s, strings.TrimSpace(l))
 	}
 	return s
 }
@@ -450,12 +164,16 @@
 }
 
 func dief(format string, args ...interface{}) {
-	fmt.Fprintf(os.Stderr, "review: "+format+"\n", args...)
+	printf(format, args...)
 	os.Exit(1)
 }
 
 func verbosef(format string, args ...interface{}) {
 	if *verbose {
-		fmt.Fprintf(os.Stderr, format, args...)
+		printf(format, args...)
 	}
 }
+
+func printf(format string, args ...interface{}) {
+	fmt.Fprintf(os.Stderr, "review: "+format+"\n", args...)
+}
diff --git a/sync.go b/sync.go
new file mode 100644
index 0000000..32381f7
--- /dev/null
+++ b/sync.go
@@ -0,0 +1,38 @@
+// 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 "flag"
+
+func doSync(args []string) {
+	if len(args) != 0 {
+		flag.Usage()
+	}
+
+	// Fetch remote changes.
+	run("git", "fetch", "-q")
+
+	// If we're on master or there's no pending change, just fast-forward.
+	branch := CurrentBranch()
+	if branch.Name == "master" || branch.ChangeID == "" {
+		run("git", "merge", "-q", "--ff-only", "origin/master")
+		return
+	}
+
+	// Don't sync with staged changes.
+	// TODO(adg): should we handle unstaged changes also?
+	if hasStagedChanges() {
+		dief("you have staged changes. Run 'review change' before sync.")
+	}
+
+	// Sync current branch to master.
+	run("git", "rebase", "-q", "origin/master")
+
+	// If the change commit has been submitted,
+	// roll back change leaving any changes unstaged.
+	if branch.Submitted() && hasPendingCommit(branch.Name) {
+		run("git", "reset", "HEAD^")
+	}
+}
diff --git a/upload.go b/upload.go
new file mode 100644
index 0000000..0bd5b83
--- /dev/null
+++ b/upload.go
@@ -0,0 +1,91 @@
+// 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 (
+	"flag"
+	"fmt"
+	"os"
+	"regexp"
+	"strings"
+)
+
+var uploadFlags struct {
+	flag.FlagSet
+	diff   bool
+	force  bool
+	rList  string
+	ccList string
+}
+
+func init() {
+	f := &uploadFlags
+	f.Usage = func() {
+		fmt.Fprintf(os.Stderr, "Usage: %s [-n] [-v] upload [-r reviewer,...] [-cc mail,...]\n", os.Args[0])
+	}
+	f.BoolVar(&f.diff, "diff", false, "show change commit diff and don't upload")
+	f.BoolVar(&f.force, "f", false, "upload even if there are staged changes")
+	f.StringVar(&f.rList, "r", "", "comma-separated list of reviewers")
+	f.StringVar(&f.ccList, "cc", "", "comma-separated list of people to CC:")
+
+}
+
+func upload(args []string) {
+	f := &uploadFlags
+	if f.Parse(args) != nil || len(f.Args()) != 0 {
+		f.Usage()
+		os.Exit(2)
+	}
+
+	branch := CurrentBranch()
+	if branch.Name == "master" {
+		dief("on master branch; can't upload.")
+	}
+	if branch.ChangeID == "" {
+		dief("no pending change; can't upload.")
+	}
+
+	if f.diff {
+		run("git", "diff", "master..HEAD")
+		return
+	}
+
+	if !f.force && hasStagedChanges() {
+		dief("there are staged changes; aborting.\n" +
+			"Use 'review change' to include them or 'review upload -f' to force upload.")
+	}
+
+	refSpec := "HEAD:refs/for/master"
+	start := "%"
+	if f.rList != "" {
+		refSpec += mailList(start, "r", f.rList)
+		start = ","
+	}
+	if f.ccList != "" {
+		refSpec += mailList(start, "cc", f.ccList)
+	}
+	run("git", "push", "-q", "origin", refSpec)
+}
+
+// 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]+$`)
+
+// mailList turns the list of mail addresses from the flag value into the format
+// expected by gerrit. The start argument is a % or , depending on where we
+// are in the processing sequence.
+func mailList(start, tag string, flagList string) string {
+	spec := start
+	for i, addr := range strings.Split(flagList, ",") {
+		if !mailAddressRE.MatchString(addr) {
+			dief("%q is not a valid reviewer mail address", addr)
+		}
+		if i > 0 {
+			spec += ","
+		}
+		spec += tag + "=" + addr
+	}
+	return spec
+}