Merge "jiri: Make sure that project Revision exists on RemoteBranch"
diff --git a/cmd/jiri/bootstrap_jiri_test.go b/cmd/jiri/bootstrap_jiri_test.go
new file mode 100644
index 0000000..3bdca29
--- /dev/null
+++ b/cmd/jiri/bootstrap_jiri_test.go
@@ -0,0 +1,62 @@
+// Copyright 2015 The Vanadium 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"
+	"path/filepath"
+	"strings"
+	"testing"
+
+	"v.io/x/lib/gosh"
+)
+
+func TestBootstrapJiri(t *testing.T) {
+	sh := gosh.NewShell(gosh.Opts{Fatalf: t.Fatalf, Logf: t.Logf, PropagateChildOutput: true})
+	defer sh.Cleanup()
+
+	bootstrap, err := filepath.Abs("./scripts/bootstrap_jiri")
+	if err != nil {
+		t.Fatalf("couldn't determine absolute path to bootstrap_jiri script")
+	}
+	rootDir := filepath.Join(sh.MakeTempDir(), "root")
+	stdout, stderr := sh.Cmd(bootstrap, rootDir).StdoutStderr()
+	if got, want := stdout, fmt.Sprintf("Please add %s to your PATH.\n", filepath.Join(rootDir, ".jiri_root", "scripts")); got != want {
+		t.Errorf("stdout got %q, want %q", got, want)
+	}
+	if got, want := stderr, ""; got != want {
+		t.Errorf("stderr got %q, want %q", got, want)
+	}
+	if _, err := os.Stat(filepath.Join(rootDir, ".jiri_root", "bin", "jiri")); err != nil {
+		t.Error(err)
+	}
+	if _, err := os.Stat(filepath.Join(rootDir, ".jiri_root", "scripts", "jiri")); err != nil {
+		t.Error(err)
+	}
+}
+
+func TestBootstrapJiriAlreadyExists(t *testing.T) {
+	sh := gosh.NewShell(gosh.Opts{Fatalf: t.Fatalf, Logf: t.Logf, PropagateChildOutput: true})
+	defer sh.Cleanup()
+
+	bootstrap, err := filepath.Abs("./scripts/bootstrap_jiri")
+	if err != nil {
+		t.Fatalf("couldn't determine absolute path to bootstrap_jiri script")
+	}
+	rootDir := sh.MakeTempDir()
+	c := sh.Cmd(bootstrap, rootDir)
+	c.ExitErrorIsOk = true
+	stdout, stderr := c.StdoutStderr()
+	if c.Err == nil {
+		t.Errorf("error got %q, want nil", c.Err)
+	}
+	if got, want := stdout, ""; got != want {
+		t.Errorf("stdout got %q, want %q", got, want)
+	}
+	if got, want := stderr, rootDir+" already exists"; !strings.Contains(got, want) {
+		t.Errorf("stderr got %q, want substr %q", got, want)
+	}
+}
diff --git a/cmd/jiri/cl.go b/cmd/jiri/cl.go
new file mode 100644
index 0000000..b5a8721
--- /dev/null
+++ b/cmd/jiri/cl.go
@@ -0,0 +1,1120 @@
+// Copyright 2015 The Vanadium 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"
+	"net/url"
+	"os"
+	"path/filepath"
+	"regexp"
+	"strings"
+
+	"v.io/jiri/collect"
+	"v.io/jiri/gerrit"
+	"v.io/jiri/gitutil"
+	"v.io/jiri/jiri"
+	"v.io/jiri/project"
+	"v.io/jiri/runutil"
+	"v.io/x/lib/cmdline"
+)
+
+const (
+	commitMessageFileName  = ".gerrit_commit_message"
+	dependencyPathFileName = ".dependency_path"
+)
+
+var (
+	autosubmitFlag   bool
+	ccsFlag          string
+	draftFlag        bool
+	editFlag         bool
+	forceFlag        bool
+	hostFlag         string
+	messageFlag      string
+	presubmitFlag    string
+	remoteBranchFlag string
+	reviewersFlag    string
+	setTopicFlag     bool
+	topicFlag        string
+	uncommittedFlag  bool
+	verifyFlag       bool
+)
+
+// Special labels stored in the commit message.
+var (
+	// Auto submit label.
+	autosubmitLabelRE *regexp.Regexp = regexp.MustCompile("AutoSubmit")
+
+	// Change-Ids start with 'I' and are followed by 40 characters of hex.
+	changeIDRE *regexp.Regexp = regexp.MustCompile("Change-Id: (I[0123456789abcdefABCDEF]{40})")
+
+	// Presubmit test label.
+	// PresubmitTest: <type>
+	presubmitTestLabelRE *regexp.Regexp = regexp.MustCompile(`PresubmitTest:\s*(.*)`)
+)
+
+// init carries out the package initialization.
+func init() {
+	cmdCLCleanup.Flags.BoolVar(&forceFlag, "f", false, `Ignore unmerged changes.`)
+	cmdCLCleanup.Flags.StringVar(&remoteBranchFlag, "remote-branch", "master", `Name of the remote branch the CL pertains to, without the leading "origin/".`)
+	cmdCLMail.Flags.BoolVar(&autosubmitFlag, "autosubmit", false, `Automatically submit the changelist when feasible.`)
+	cmdCLMail.Flags.StringVar(&ccsFlag, "cc", "", `Comma-seperated list of emails or LDAPs to cc.`)
+	cmdCLMail.Flags.BoolVar(&draftFlag, "d", false, `Send a draft changelist.`)
+	cmdCLMail.Flags.BoolVar(&editFlag, "edit", true, `Open an editor to edit the CL description.`)
+	cmdCLMail.Flags.StringVar(&hostFlag, "host", "", `Gerrit host to use.  Defaults to gerrit host specified in manifest.`)
+	cmdCLMail.Flags.StringVar(&messageFlag, "m", "", `CL description.`)
+	cmdCLMail.Flags.StringVar(&presubmitFlag, "presubmit", string(gerrit.PresubmitTestTypeAll),
+		fmt.Sprintf("The type of presubmit tests to run. Valid values: %s.", strings.Join(gerrit.PresubmitTestTypes(), ",")))
+	cmdCLMail.Flags.StringVar(&remoteBranchFlag, "remote-branch", "master", `Name of the remote branch the CL pertains to, without the leading "origin/".`)
+	cmdCLMail.Flags.StringVar(&reviewersFlag, "r", "", `Comma-seperated list of emails or LDAPs to request review.`)
+	cmdCLMail.Flags.BoolVar(&setTopicFlag, "set-topic", true, `Set Gerrit CL topic.`)
+	cmdCLMail.Flags.StringVar(&topicFlag, "topic", "", `CL topic, defaults to <username>-<branchname>.`)
+	cmdCLMail.Flags.BoolVar(&uncommittedFlag, "check-uncommitted", true, `Check that no uncommitted changes exist.`)
+	cmdCLMail.Flags.BoolVar(&verifyFlag, "verify", true, `Run pre-push git hooks.`)
+	cmdCLSync.Flags.StringVar(&remoteBranchFlag, "remote-branch", "master", `Name of the remote branch the CL pertains to, without the leading "origin/".`)
+}
+
+func getCommitMessageFileName(jirix *jiri.X, branch string) (string, error) {
+	topLevel, err := gitutil.New(jirix.NewSeq()).TopLevel()
+	if err != nil {
+		return "", err
+	}
+	return filepath.Join(topLevel, jiri.ProjectMetaDir, branch, commitMessageFileName), nil
+}
+
+func getDependencyPathFileName(jirix *jiri.X, branch string) (string, error) {
+	topLevel, err := gitutil.New(jirix.NewSeq()).TopLevel()
+	if err != nil {
+		return "", err
+	}
+	return filepath.Join(topLevel, jiri.ProjectMetaDir, branch, dependencyPathFileName), nil
+}
+
+func getDependentCLs(jirix *jiri.X, branch string) ([]string, error) {
+	file, err := getDependencyPathFileName(jirix, branch)
+	if err != nil {
+		return nil, err
+	}
+	data, err := jirix.NewSeq().ReadFile(file)
+	var branches []string
+	if err != nil {
+		if !runutil.IsNotExist(err) {
+			return nil, err
+		}
+		if branch != remoteBranchFlag {
+			branches = []string{remoteBranchFlag}
+		}
+	} else {
+		branches = strings.Split(strings.TrimSpace(string(data)), "\n")
+	}
+	return branches, nil
+}
+
+// cmdCL represents the "jiri cl" command.
+var cmdCL = &cmdline.Command{
+	Name:     "cl",
+	Short:    "Manage project changelists",
+	Long:     "Manage project changelists.",
+	Children: []*cmdline.Command{cmdCLCleanup, cmdCLMail, cmdCLNew, cmdCLSync},
+}
+
+// cmdCLCleanup represents the "jiri cl cleanup" command.
+//
+// TODO(jsimsa): Replace this with a "submit" command that talks to
+// Gerrit to submit the CL and then (optionally) removes it locally.
+var cmdCLCleanup = &cmdline.Command{
+	Runner: jiri.RunnerFunc(runCLCleanup),
+	Name:   "cleanup",
+	Short:  "Clean up changelists that have been merged",
+	Long: `
+Command "cleanup" checks that the given branches have been merged into
+the corresponding remote branch. If a branch differs from the
+corresponding remote branch, the command reports the difference and
+stops. Otherwise, it deletes the given branches.
+`,
+	ArgsName: "<branches>",
+	ArgsLong: "<branches> is a list of branches to cleanup.",
+}
+
+func cleanupCL(jirix *jiri.X, branches []string) (e error) {
+	originalBranch, err := gitutil.New(jirix.NewSeq()).CurrentBranchName()
+	if err != nil {
+		return err
+	}
+	stashed, err := gitutil.New(jirix.NewSeq()).Stash()
+	if err != nil {
+		return err
+	}
+	if stashed {
+		defer collect.Error(func() error { return gitutil.New(jirix.NewSeq()).StashPop() }, &e)
+	}
+	if err := gitutil.New(jirix.NewSeq()).CheckoutBranch(remoteBranchFlag); err != nil {
+		return err
+	}
+	checkoutOriginalBranch := true
+	defer collect.Error(func() error {
+		if checkoutOriginalBranch {
+			return gitutil.New(jirix.NewSeq()).CheckoutBranch(originalBranch)
+		}
+		return nil
+	}, &e)
+	if err := gitutil.New(jirix.NewSeq()).FetchRefspec("origin", remoteBranchFlag); err != nil {
+		return err
+	}
+	s := jirix.NewSeq()
+	for _, branch := range branches {
+		cleanupFn := func() error { return cleanupBranch(jirix, branch) }
+		if err := s.Call(cleanupFn, "Cleaning up branch: %s", branch).Done(); err != nil {
+			return err
+		}
+		if branch == originalBranch {
+			checkoutOriginalBranch = false
+		}
+	}
+	return nil
+}
+
+func cleanupBranch(jirix *jiri.X, branch string) error {
+	if err := gitutil.New(jirix.NewSeq()).CheckoutBranch(branch); err != nil {
+		return err
+	}
+	if !forceFlag {
+		trackingBranch := "origin/" + remoteBranchFlag
+		if err := gitutil.New(jirix.NewSeq()).Merge(trackingBranch); err != nil {
+			return err
+		}
+		files, err := gitutil.New(jirix.NewSeq()).ModifiedFiles(trackingBranch, branch)
+		if err != nil {
+			return err
+		}
+		if len(files) != 0 {
+			return fmt.Errorf("unmerged changes in\n%s", strings.Join(files, "\n"))
+		}
+	}
+	if err := gitutil.New(jirix.NewSeq()).CheckoutBranch(remoteBranchFlag); err != nil {
+		return err
+	}
+	if err := gitutil.New(jirix.NewSeq()).DeleteBranch(branch, gitutil.ForceOpt(true)); err != nil {
+		return err
+	}
+	reviewBranch := branch + "-REVIEW"
+	if gitutil.New(jirix.NewSeq()).BranchExists(reviewBranch) {
+		if err := gitutil.New(jirix.NewSeq()).DeleteBranch(reviewBranch, gitutil.ForceOpt(true)); err != nil {
+			return err
+		}
+	}
+	// Delete branch metadata.
+	topLevel, err := gitutil.New(jirix.NewSeq()).TopLevel()
+	if err != nil {
+		return err
+	}
+	s := jirix.NewSeq()
+	// Remove the branch from all dependency paths.
+	metadataDir := filepath.Join(topLevel, jiri.ProjectMetaDir)
+	fileInfos, err := s.RemoveAll(filepath.Join(metadataDir, branch)).
+		ReadDir(metadataDir)
+	if err != nil {
+		return err
+	}
+	for _, fileInfo := range fileInfos {
+		if !fileInfo.IsDir() {
+			continue
+		}
+		file, err := getDependencyPathFileName(jirix, fileInfo.Name())
+		if err != nil {
+			return err
+		}
+		data, err := s.ReadFile(file)
+		if err != nil {
+			if !runutil.IsNotExist(err) {
+				return err
+			}
+			continue
+		}
+		branches := strings.Split(string(data), "\n")
+		for i, tmpBranch := range branches {
+			if branch == tmpBranch {
+				data := []byte(strings.Join(append(branches[:i], branches[i+1:]...), "\n"))
+				if err := s.WriteFile(file, data, os.FileMode(0644)).Done(); err != nil {
+					return err
+				}
+				break
+			}
+		}
+	}
+	return nil
+}
+
+func runCLCleanup(jirix *jiri.X, args []string) error {
+	if len(args) == 0 {
+		return jirix.UsageErrorf("cleanup requires at least one argument")
+	}
+	return cleanupCL(jirix, args)
+}
+
+// cmdCLMail represents the "jiri cl mail" command.
+var cmdCLMail = &cmdline.Command{
+	Runner: jiri.RunnerFunc(runCLMail),
+	Name:   "mail",
+	Short:  "Mail a changelist for review",
+	Long: `
+Command "mail" squashes all commits of a local branch into a single
+"changelist" and mails this changelist to Gerrit as a single
+commit. First time the command is invoked, it generates a Change-Id
+for the changelist, which is appended to the commit
+message. Consecutive invocations of the command use the same Change-Id
+by default, informing Gerrit that the incomming commit is an update of
+an existing changelist.
+`,
+}
+
+type changeConflictError struct {
+	localBranch  string
+	message      string
+	remoteBranch string
+}
+
+func (e changeConflictError) Error() string {
+	result := "changelist conflicts with the remote " + e.remoteBranch + " branch\n\n"
+	result += "To resolve this problem, run 'git pull origin " + e.remoteBranch + ":" + e.localBranch + "',\n"
+	result += "resolve the conflicts identified below, and then try again.\n"
+	result += e.message
+	return result
+}
+
+type emptyChangeError struct{}
+
+func (_ emptyChangeError) Error() string {
+	return "current branch has no commits"
+}
+
+type gerritError string
+
+func (e gerritError) Error() string {
+	result := "sending code review failed\n\n"
+	result += string(e)
+	return result
+}
+
+type noChangeIDError struct{}
+
+func (_ noChangeIDError) Error() string {
+	result := "changelist is missing a Change-ID"
+	return result
+}
+
+type uncommittedChangesError []string
+
+func (e uncommittedChangesError) Error() string {
+	result := "uncommitted local changes in files:\n"
+	result += "  " + strings.Join(e, "\n  ")
+	return result
+}
+
+var defaultMessageHeader = `
+# Describe your changelist, specifying what package(s) your change
+# pertains to, followed by a short summary and, in case of non-trivial
+# changelists, provide a detailed description.
+#
+# For example:
+#
+# rpc/stream/proxy: add publish address
+#
+# The listen address is not always the same as the address that external
+# users need to connect to. This CL adds a new argument to proxy.New()
+# to specify the published address that clients should connect to.
+
+# FYI, you are about to submit the following local commits for review:
+#
+`
+
+// currentProject returns the Project containing the current working directory.
+// The current working directory must be inside JIRI_ROOT.
+func currentProject(jirix *jiri.X) (project.Project, error) {
+	dir, err := os.Getwd()
+	if err != nil {
+		return project.Project{}, fmt.Errorf("os.Getwd() failed: %v", err)
+	}
+
+	// Error if current working dir is not inside jirix.Root.
+	if !strings.HasPrefix(dir, jirix.Root) {
+		return project.Project{}, fmt.Errorf("'jiri cl mail' must be run from within a project in JIRI_ROOT")
+	}
+
+	// Walk up the path until we find a project at that path, or hit the jirix.Root.
+	for dir != jirix.Root {
+		p, err := project.ProjectAtPath(jirix, dir)
+		if err != nil {
+			dir = filepath.Dir(dir)
+			continue
+		}
+		return p, nil
+	}
+	return project.Project{}, fmt.Errorf("directory %q is not contained in a project", dir)
+}
+
+// runCLMail is a wrapper that sets up and runs a review instance.
+func runCLMail(jirix *jiri.X, _ []string) error {
+	// Sanity checks for the <presubmitFlag> flag.
+	if !checkPresubmitFlag() {
+		return jirix.UsageErrorf("invalid value for the -presubmit flag. Valid values: %s.",
+			strings.Join(gerrit.PresubmitTestTypes(), ","))
+	}
+
+	p, err := currentProject(jirix)
+	if err != nil {
+		return err
+	}
+
+	host := hostFlag
+	if host == "" {
+		if p.GerritHost == "" {
+			return fmt.Errorf("No gerrit host found.  Please use the '--host' flag, or add a 'gerrithost' attribute for project %q.", p.Name)
+		}
+		host = p.GerritHost
+	}
+	hostUrl, err := url.Parse(host)
+	if err != nil {
+		return fmt.Errorf("invalid Gerrit host %q: %v", host, err)
+	}
+	projectRemoteUrl, err := url.Parse(p.Remote)
+	if err != nil {
+		return fmt.Errorf("invalid project remote: %v", p.Remote, err)
+	}
+	gerritRemote := *hostUrl
+	gerritRemote.Path = projectRemoteUrl.Path
+
+	// Create and run the review.
+	review, err := newReview(jirix, gerrit.CLOpts{
+		Autosubmit:   autosubmitFlag,
+		Ccs:          parseEmails(ccsFlag),
+		Draft:        draftFlag,
+		Edit:         editFlag,
+		Remote:       gerritRemote.String(),
+		Host:         hostUrl,
+		Presubmit:    gerrit.PresubmitTestType(presubmitFlag),
+		RemoteBranch: remoteBranchFlag,
+		Reviewers:    parseEmails(reviewersFlag),
+		Verify:       verifyFlag,
+	})
+	if err != nil {
+		return err
+	}
+	if confirmed, err := review.confirmFlagChanges(); err != nil {
+		return err
+	} else if !confirmed {
+		return nil
+	}
+	return review.run()
+}
+
+// parseEmails input a list of comma separated tokens and outputs a
+// list of email addresses. The tokens can either be email addresses
+// or Google LDAPs in which case the suffix @google.com is appended to
+// them to turn them into email addresses.
+func parseEmails(value string) []string {
+	var emails []string
+	tokens := strings.Split(value, ",")
+	for _, token := range tokens {
+		if token == "" {
+			continue
+		}
+		if !strings.Contains(token, "@") {
+			token += "@google.com"
+		}
+		emails = append(emails, token)
+	}
+	return emails
+}
+
+// checkDependents makes sure that all CLs in the sequence of
+// dependent CLs leading to (but not including) the current branch
+// have been exported to Gerrit.
+func checkDependents(jirix *jiri.X) (e error) {
+	originalBranch, err := gitutil.New(jirix.NewSeq()).CurrentBranchName()
+	if err != nil {
+		return err
+	}
+	branches, err := getDependentCLs(jirix, originalBranch)
+	if err != nil {
+		return err
+	}
+	for i := 1; i < len(branches); i++ {
+		file, err := getCommitMessageFileName(jirix, branches[i])
+		if err != nil {
+			return err
+		}
+		if _, err := jirix.NewSeq().Stat(file); err != nil {
+			if !runutil.IsNotExist(err) {
+				return err
+			}
+			return fmt.Errorf(`Failed to export the branch %q to Gerrit because its ancestor %q has not been exported to Gerrit yet.
+The following steps are needed before the operation can be retried:
+$ git checkout %v
+$ jiri cl mail
+$ git checkout %v
+# retry the original command
+`, originalBranch, branches[i], branches[i], originalBranch)
+		}
+	}
+
+	return nil
+}
+
+type review struct {
+	jirix        *jiri.X
+	reviewBranch string
+	gerrit.CLOpts
+}
+
+func newReview(jirix *jiri.X, opts gerrit.CLOpts) (*review, error) {
+	// Sync all CLs in the sequence of dependent CLs ending in the
+	// current branch.
+	if err := syncCL(jirix); err != nil {
+		return nil, err
+	}
+
+	// Make sure that all CLs in the above sequence (possibly except for
+	// the current branch) have been exported to Gerrit. This is needed
+	// to make sure we have commit messages for all but the last CL.
+	//
+	// NOTE: The alternative here is to prompt the user for multiple
+	// commit messages, which seems less user friendly.
+	if err := checkDependents(jirix); err != nil {
+		return nil, err
+	}
+
+	branch, err := gitutil.New(jirix.NewSeq()).CurrentBranchName()
+	if err != nil {
+		return nil, err
+	}
+	opts.Branch = branch
+	if opts.Topic == "" {
+		opts.Topic = fmt.Sprintf("%s-%s", os.Getenv("USER"), branch) // use <username>-<branchname> as the default
+	}
+	if opts.Presubmit == gerrit.PresubmitTestType("") {
+		opts.Presubmit = gerrit.PresubmitTestTypeAll // use gerrit.PresubmitTestTypeAll as the default
+	}
+	if opts.RemoteBranch == "" {
+		opts.RemoteBranch = "master" // use master as the default
+	}
+	return &review{
+		jirix:        jirix,
+		reviewBranch: branch + "-REVIEW",
+		CLOpts:       opts,
+	}, nil
+}
+
+func checkPresubmitFlag() bool {
+	for _, t := range gerrit.PresubmitTestTypes() {
+		if presubmitFlag == t {
+			return true
+		}
+	}
+	return false
+}
+
+// confirmFlagChanges asks users to confirm if any of the
+// presubmit and autosubmit flags changes.
+func (review *review) confirmFlagChanges() (bool, error) {
+	file, err := getCommitMessageFileName(review.jirix, review.CLOpts.Branch)
+	if err != nil {
+		return false, err
+	}
+	bytes, err := review.jirix.NewSeq().ReadFile(file)
+	if err != nil {
+		if runutil.IsNotExist(err) {
+			return true, nil
+		}
+		return false, err
+	}
+	content := string(bytes)
+	changes := []string{}
+
+	// Check presubmit label change.
+	prevPresubmitType := string(gerrit.PresubmitTestTypeAll)
+	matches := presubmitTestLabelRE.FindStringSubmatch(content)
+	if matches != nil {
+		prevPresubmitType = matches[1]
+	}
+	if presubmitFlag != prevPresubmitType {
+		changes = append(changes, fmt.Sprintf("- presubmit=%s to presubmit=%s", prevPresubmitType, presubmitFlag))
+	}
+
+	// Check autosubmit label change.
+	prevAutosubmit := autosubmitLabelRE.MatchString(content)
+	if autosubmitFlag != prevAutosubmit {
+		changes = append(changes, fmt.Sprintf("- autosubmit=%v to autosubmit=%v", prevAutosubmit, autosubmitFlag))
+
+	}
+
+	if len(changes) > 0 {
+		fmt.Printf("Changes:\n%s\n", strings.Join(changes, "\n"))
+		fmt.Print("Are you sure you want to make the above changes? y/N:")
+		var response string
+		if _, err := fmt.Scanf("%s\n", &response); err != nil || response != "y" {
+			return false, nil
+		}
+	}
+	return true, nil
+}
+
+// cleanup cleans up after the review.
+func (review *review) cleanup(stashed bool) error {
+	if err := gitutil.New(review.jirix.NewSeq()).CheckoutBranch(review.CLOpts.Branch); err != nil {
+		return err
+	}
+	if gitutil.New(review.jirix.NewSeq()).BranchExists(review.reviewBranch) {
+		if err := gitutil.New(review.jirix.NewSeq()).DeleteBranch(review.reviewBranch, gitutil.ForceOpt(true)); err != nil {
+			return err
+		}
+	}
+	if stashed {
+		if err := gitutil.New(review.jirix.NewSeq()).StashPop(); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+// createReviewBranch creates a clean review branch from the remote
+// branch this CL pertains to and then iterates over the sequence of
+// dependent CLs leading to the current branch, creating one commit
+// per CL by squashing all commits of each individual CL. The commit
+// message for all but that last CL is derived from their
+// <commitMessageFileName>, while the <message> argument is used as
+// the commit message for the last commit.
+func (review *review) createReviewBranch(message string) (e error) {
+	// Create the review branch.
+	if err := gitutil.New(review.jirix.NewSeq()).FetchRefspec("origin", review.CLOpts.RemoteBranch); err != nil {
+		return err
+	}
+	if gitutil.New(review.jirix.NewSeq()).BranchExists(review.reviewBranch) {
+		if err := gitutil.New(review.jirix.NewSeq()).DeleteBranch(review.reviewBranch, gitutil.ForceOpt(true)); err != nil {
+			return err
+		}
+	}
+	upstream := "origin/" + review.CLOpts.RemoteBranch
+	if err := gitutil.New(review.jirix.NewSeq()).CreateBranchWithUpstream(review.reviewBranch, upstream); err != nil {
+		return err
+	}
+	if err := gitutil.New(review.jirix.NewSeq()).CheckoutBranch(review.reviewBranch); err != nil {
+		return err
+	}
+	// Register a cleanup handler in case of subsequent errors.
+	cleanup := true
+	defer collect.Error(func() error {
+		if !cleanup {
+			return gitutil.New(review.jirix.NewSeq()).CheckoutBranch(review.CLOpts.Branch)
+		}
+		gitutil.New(review.jirix.NewSeq()).CheckoutBranch(review.CLOpts.Branch, gitutil.ForceOpt(true))
+		gitutil.New(review.jirix.NewSeq()).DeleteBranch(review.reviewBranch, gitutil.ForceOpt(true))
+		return nil
+	}, &e)
+
+	// Report an error if the CL is empty.
+	if !review.jirix.DryRun() {
+		hasDiff, err := gitutil.New(review.jirix.NewSeq()).BranchesDiffer(review.CLOpts.Branch, review.reviewBranch)
+		if err != nil {
+			return err
+		}
+		if !hasDiff {
+			return emptyChangeError(struct{}{})
+		}
+	}
+
+	// If <message> is empty, replace it with the default message.
+	if len(message) == 0 {
+		var err error
+		message, err = review.defaultCommitMessage()
+		if err != nil {
+			return err
+		}
+	}
+
+	// Iterate over all dependent CLs leading to (and including) the
+	// current branch, creating one commit in the review branch per CL
+	// by squashing all commits of each individual CL.
+	branches, err := getDependentCLs(review.jirix, review.CLOpts.Branch)
+	if err != nil {
+		return err
+	}
+	branches = append(branches, review.CLOpts.Branch)
+	if err := review.squashBranches(branches, message); err != nil {
+		return err
+	}
+
+	cleanup = false
+	return nil
+}
+
+// squashBranches iterates over the given list of branches, creating
+// one commit per branch in the current branch by squashing all
+// commits of each individual branch.
+//
+// TODO(jsimsa): Consider using "git rebase --onto" to avoid having to
+// deal with merge conflicts.
+func (review *review) squashBranches(branches []string, message string) (e error) {
+	for i := 1; i < len(branches); i++ {
+		// We want to merge the <branches[i]> branch on top of the review
+		// branch, forcing all conflicts to be reviewed in favor of the
+		// <branches[i]> branch. Unfortunately, git merge does not offer a
+		// strategy that would do that for us. The solution implemented
+		// here is based on:
+		//
+		// http://stackoverflow.com/questions/173919/is-there-a-theirs-version-of-git-merge-s-ours
+		if err := gitutil.New(review.jirix.NewSeq()).Merge(branches[i], gitutil.SquashOpt(true), gitutil.StrategyOpt("ours")); err != nil {
+			return changeConflictError{
+				localBranch:  branches[i],
+				remoteBranch: review.CLOpts.RemoteBranch,
+				message:      err.Error(),
+			}
+		}
+		// Fetch the timestamp of the last commit of <branches[i]> and use
+		// it to create the squashed commit. This is needed to make sure
+		// that the commit hash of the squashed commit stays the same as
+		// long as the squashed sequence of commits does not change. If
+		// this was not the case, consecutive invocations of "jiri cl mail"
+		// could fail if some, but not all, of the dependent CLs submitted
+		// to Gerrit have changed.
+		output, err := gitutil.New(review.jirix.NewSeq()).Log(branches[i], branches[i]+"^", "%ad%n%cd")
+		if err != nil {
+			return err
+		}
+		if len(output) < 1 || len(output[0]) < 2 {
+			return fmt.Errorf("unexpected output length: %v", output)
+		}
+		authorDate := gitutil.AuthorDateOpt(output[0][0])
+		committer := gitutil.CommitterDateOpt(output[0][1])
+		git := gitutil.New(review.jirix.NewSeq(), authorDate, committer)
+		if i < len(branches)-1 {
+			file, err := getCommitMessageFileName(review.jirix, branches[i])
+			if err != nil {
+				return err
+			}
+			message, err := review.jirix.NewSeq().ReadFile(file)
+			if err != nil {
+				return err
+			}
+			if err := git.CommitWithMessage(string(message)); err != nil {
+				return err
+			}
+		} else {
+			committer := git.NewCommitter(review.CLOpts.Edit)
+			if err := committer.Commit(message); err != nil {
+				return err
+			}
+		}
+		tmpBranch := review.reviewBranch + "-" + branches[i] + "-TMP"
+		if err := git.CreateBranch(tmpBranch); err != nil {
+			return err
+		}
+		defer collect.Error(func() error {
+			return gitutil.New(review.jirix.NewSeq()).DeleteBranch(tmpBranch, gitutil.ForceOpt(true))
+		}, &e)
+		if err := git.Reset(branches[i]); err != nil {
+			return err
+		}
+		if err := git.Reset(tmpBranch, gitutil.ModeOpt("soft")); err != nil {
+			return err
+		}
+		if err := git.CommitAmend(); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+// defaultCommitMessage creates the default commit message from the
+// list of commits on the branch.
+func (review *review) defaultCommitMessage() (string, error) {
+	commitMessages, err := gitutil.New(review.jirix.NewSeq()).CommitMessages(review.CLOpts.Branch, review.reviewBranch)
+	if err != nil {
+		return "", err
+	}
+	// Strip "Change-Id: ..." from the commit messages.
+	strippedMessages := changeIDRE.ReplaceAllLiteralString(commitMessages, "")
+	// Add comment markers (#) to every line.
+	commentedMessages := "# " + strings.Replace(strippedMessages, "\n", "\n# ", -1)
+	message := defaultMessageHeader + commentedMessages
+	return message, nil
+}
+
+// ensureChangeID makes sure that the last commit contains a Change-Id, and
+// returns an error if it does not.
+func (review *review) ensureChangeID() error {
+	latestCommitMessage, err := gitutil.New(review.jirix.NewSeq()).LatestCommitMessage()
+	if err != nil {
+		return err
+	}
+	changeID := changeIDRE.FindString(latestCommitMessage)
+	if changeID == "" {
+		return noChangeIDError(struct{}{})
+	}
+	return nil
+}
+
+// processLabels adds/removes labels for the given commit message.
+func (review *review) processLabels(message string) string {
+	// Find the Change-ID line.
+	changeIDLine := changeIDRE.FindString(message)
+
+	// Strip existing labels and change-ID.
+	message = autosubmitLabelRE.ReplaceAllLiteralString(message, "")
+	message = presubmitTestLabelRE.ReplaceAllLiteralString(message, "")
+	message = changeIDRE.ReplaceAllLiteralString(message, "")
+
+	// Insert labels and change-ID back.
+	if review.CLOpts.Autosubmit {
+		message += fmt.Sprintf("AutoSubmit\n")
+	}
+	if review.CLOpts.Presubmit != gerrit.PresubmitTestTypeAll {
+		message += fmt.Sprintf("PresubmitTest: %s\n", review.CLOpts.Presubmit)
+	}
+	if changeIDLine != "" && !strings.HasSuffix(message, "\n") {
+		message += "\n"
+	}
+	message += changeIDLine
+
+	return message
+}
+
+// run implements checks that the review passes all local checks
+// and then mails it to Gerrit.
+func (review *review) run() (e error) {
+	if uncommittedFlag {
+		changes, err := gitutil.New(review.jirix.NewSeq()).FilesWithUncommittedChanges()
+		if err != nil {
+			return err
+		}
+		if len(changes) != 0 {
+			return uncommittedChangesError(changes)
+		}
+	}
+	if review.CLOpts.Branch == remoteBranchFlag {
+		return fmt.Errorf("cannot do a review from the %q branch.", remoteBranchFlag)
+	}
+	stashed, err := gitutil.New(review.jirix.NewSeq()).Stash()
+	if err != nil {
+		return err
+	}
+	wd, err := os.Getwd()
+	if err != nil {
+		return fmt.Errorf("Getwd() failed: %v", err)
+	}
+	defer collect.Error(func() error { return review.jirix.NewSeq().Chdir(wd).Done() }, &e)
+	topLevel, err := gitutil.New(review.jirix.NewSeq()).TopLevel()
+	if err != nil {
+		return err
+	}
+	s := review.jirix.NewSeq()
+	if err := s.Chdir(topLevel).Done(); err != nil {
+		return err
+	}
+	defer collect.Error(func() error { return review.cleanup(stashed) }, &e)
+	file, err := getCommitMessageFileName(review.jirix, review.CLOpts.Branch)
+	if err != nil {
+		return err
+	}
+	message := messageFlag
+	if message == "" {
+		// Message was not passed in flag.  Attempt to read it from file.
+		data, err := s.ReadFile(file)
+		if err != nil {
+			if !runutil.IsNotExist(err) {
+				return err
+			}
+		} else {
+			message = string(data)
+		}
+	}
+
+	// Add/remove labels to/from the commit message before asking users
+	// to edit it. We do this only when this is not the initial commit
+	// where the message is empty.
+	//
+	// For the initial commit, the labels will be processed after the
+	// message is edited by users, which happens in the
+	// updateReviewMessage method.
+	if message != "" {
+		message = review.processLabels(message)
+	}
+	if err := review.createReviewBranch(message); err != nil {
+		return err
+	}
+	if err := review.updateReviewMessage(file); err != nil {
+		return err
+	}
+	if err := review.send(); err != nil {
+		return err
+	}
+	if setTopicFlag {
+		if err := review.setTopic(); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+// send mails the current branch out for review.
+func (review *review) send() error {
+	if !review.jirix.DryRun() {
+		if err := review.ensureChangeID(); err != nil {
+			return err
+		}
+	}
+	if err := gerrit.Push(review.jirix.NewSeq(), review.CLOpts); err != nil {
+		return gerritError(err.Error())
+	}
+	return nil
+}
+
+// getChangeID reads the commit message and extracts the change-Id.
+func (review *review) getChangeID() (string, error) {
+	file, err := getCommitMessageFileName(review.jirix, review.CLOpts.Branch)
+	if err != nil {
+		return "", err
+	}
+	bytes, err := review.jirix.NewSeq().ReadFile(file)
+	if err != nil {
+		return "", err
+	}
+	changeID := changeIDRE.FindSubmatch(bytes)
+	if changeID == nil || len(changeID) < 2 {
+		return "", fmt.Errorf("could not find Change-Id in:\n%s", bytes)
+	}
+	return string(changeID[1]), nil
+}
+
+// setTopic sets the topic for the CL corresponding to the branch the
+// review was created for.
+func (review *review) setTopic() error {
+	changeID, err := review.getChangeID()
+	if err != nil {
+		return err
+	}
+	host := review.CLOpts.Host
+	if host.Scheme != "http" && host.Scheme != "https" {
+		return fmt.Errorf("Cannot set topic for gerrit host %q. Please use a host url with 'https' scheme or run with '--set-topic=false'.", host.String())
+	}
+	if err := review.jirix.Gerrit(host).SetTopic(changeID, review.CLOpts); err != nil {
+		return err
+	}
+	return nil
+}
+
+// updateReviewMessage writes the commit message to the given file.
+func (review *review) updateReviewMessage(file string) error {
+	if err := gitutil.New(review.jirix.NewSeq()).CheckoutBranch(review.reviewBranch); err != nil {
+		return err
+	}
+	newMessage, err := gitutil.New(review.jirix.NewSeq()).LatestCommitMessage()
+	if err != nil {
+		return err
+	}
+	s := review.jirix.NewSeq()
+	// For the initial commit where the commit message file doesn't exist,
+	// add/remove labels after users finish editing the commit message.
+	//
+	// This behavior is consistent with how Change-ID is added for the
+	// initial commit so we don't confuse users.
+	if _, err := s.Stat(file); err != nil {
+		if runutil.IsNotExist(err) {
+			newMessage = review.processLabels(newMessage)
+			if err := gitutil.New(review.jirix.NewSeq()).CommitAmendWithMessage(newMessage); err != nil {
+				return err
+			}
+		} else {
+			return err
+		}
+	}
+	topLevel, err := gitutil.New(review.jirix.NewSeq()).TopLevel()
+	if err != nil {
+		return err
+	}
+	newMetadataDir := filepath.Join(topLevel, jiri.ProjectMetaDir, review.CLOpts.Branch)
+	if err := s.MkdirAll(newMetadataDir, os.FileMode(0755)).
+		WriteFile(file, []byte(newMessage), 0644).Done(); err != nil {
+		return err
+	}
+	return nil
+}
+
+// cmdCLNew represents the "jiri cl new" command.
+var cmdCLNew = &cmdline.Command{
+	Runner: jiri.RunnerFunc(runCLNew),
+	Name:   "new",
+	Short:  "Create a new local branch for a changelist",
+	Long: fmt.Sprintf(`
+Command "new" creates a new local branch for a changelist. In
+particular, it forks a new branch with the given name from the current
+branch and records the relationship between the current branch and the
+new branch in the %v metadata directory. The information recorded in
+the %v metadata directory tracks dependencies between CLs and is used
+by the "jiri cl sync" and "jiri cl mail" commands.
+`, jiri.ProjectMetaDir, jiri.ProjectMetaDir),
+	ArgsName: "<name>",
+	ArgsLong: "<name> is the changelist name.",
+}
+
+func runCLNew(jirix *jiri.X, args []string) error {
+	if got, want := len(args), 1; got != want {
+		return jirix.UsageErrorf("unexpected number of arguments: got %v, want %v", got, want)
+	}
+	return newCL(jirix, args)
+}
+
+func newCL(jirix *jiri.X, args []string) error {
+	topLevel, err := gitutil.New(jirix.NewSeq()).TopLevel()
+	if err != nil {
+		return err
+	}
+	originalBranch, err := gitutil.New(jirix.NewSeq()).CurrentBranchName()
+	if err != nil {
+		return err
+	}
+
+	// Create a new branch using the current branch.
+	newBranch := args[0]
+	if err := gitutil.New(jirix.NewSeq()).CreateAndCheckoutBranch(newBranch); err != nil {
+		return err
+	}
+
+	// Register a cleanup handler in case of subsequent errors.
+	cleanup := true
+	defer func() {
+		if cleanup {
+			gitutil.New(jirix.NewSeq()).CheckoutBranch(originalBranch, gitutil.ForceOpt(true))
+			gitutil.New(jirix.NewSeq()).DeleteBranch(newBranch, gitutil.ForceOpt(true))
+		}
+	}()
+
+	s := jirix.NewSeq()
+	// Record the dependent CLs for the new branch. The dependent CLs
+	// are recorded in a <dependencyPathFileName> file as a
+	// newline-separated list of branch names.
+	branches, err := getDependentCLs(jirix, originalBranch)
+	if err != nil {
+		return err
+	}
+	branches = append(branches, originalBranch)
+	newMetadataDir := filepath.Join(topLevel, jiri.ProjectMetaDir, newBranch)
+	if err := s.MkdirAll(newMetadataDir, os.FileMode(0755)).Done(); err != nil {
+		return err
+	}
+	file, err := getDependencyPathFileName(jirix, newBranch)
+	if err != nil {
+		return err
+	}
+	if err := s.WriteFile(file, []byte(strings.Join(branches, "\n")), os.FileMode(0644)).Done(); err != nil {
+		return err
+	}
+
+	cleanup = false
+	return nil
+}
+
+// cmdCLSync represents the "jiri cl sync" command.
+var cmdCLSync = &cmdline.Command{
+	Runner: jiri.RunnerFunc(runCLSync),
+	Name:   "sync",
+	Short:  "Bring a changelist up to date",
+	Long: fmt.Sprintf(`
+Command "sync" brings the CL identified by the current branch up to
+date with the branch tracking the remote branch this CL pertains
+to. To do that, the command uses the information recorded in the %v
+metadata directory to identify the sequence of dependent CLs leading
+to the current branch. The command then iterates over this sequence
+bringing each of the CLs up to date with its ancestor. The end result
+of this process is that all CLs in the sequence are up to date with
+the branch that tracks the remote branch this CL pertains to.
+
+NOTE: It is possible that the command cannot automatically merge
+changes in an ancestor into its dependent. When that occurs, the
+command is aborted and prints instructions that need to be followed
+before the command can be retried.
+`, jiri.ProjectMetaDir),
+}
+
+func runCLSync(jirix *jiri.X, _ []string) error {
+	return syncCL(jirix)
+}
+
+func syncCL(jirix *jiri.X) (e error) {
+	stashed, err := gitutil.New(jirix.NewSeq()).Stash()
+	if err != nil {
+		return err
+	}
+	if stashed {
+		defer collect.Error(func() error { return gitutil.New(jirix.NewSeq()).StashPop() }, &e)
+	}
+
+	// Register a cleanup handler in case of subsequent errors.
+	forceOriginalBranch := true
+	originalBranch, err := gitutil.New(jirix.NewSeq()).CurrentBranchName()
+	if err != nil {
+		return err
+	}
+	originalWd, err := os.Getwd()
+	if err != nil {
+		return err
+	}
+
+	defer func() {
+		if forceOriginalBranch {
+			gitutil.New(jirix.NewSeq()).CheckoutBranch(originalBranch, gitutil.ForceOpt(true))
+		}
+		jirix.NewSeq().Chdir(originalWd)
+	}()
+
+	s := jirix.NewSeq()
+	// Switch to an existing directory in master so we can run commands.
+	topLevel, err := gitutil.New(jirix.NewSeq()).TopLevel()
+	if err != nil {
+		return err
+	}
+	if err := s.Chdir(topLevel).Done(); err != nil {
+		return err
+	}
+
+	// Identify the dependents CLs leading to (and including) the
+	// current branch.
+	branches, err := getDependentCLs(jirix, originalBranch)
+	if err != nil {
+		return err
+	}
+	branches = append(branches, originalBranch)
+
+	// Sync from upstream.
+	if err := gitutil.New(jirix.NewSeq()).CheckoutBranch(branches[0]); err != nil {
+		return err
+	}
+	if err := gitutil.New(jirix.NewSeq()).Pull("origin", branches[0]); err != nil {
+		return err
+	}
+
+	// Bring all CLs in the sequence of dependent CLs leading to the
+	// current branch up to date with the <remoteBranchFlag> branch.
+	for i := 1; i < len(branches); i++ {
+		if err := gitutil.New(jirix.NewSeq()).CheckoutBranch(branches[i]); err != nil {
+			return err
+		}
+		if err := gitutil.New(jirix.NewSeq()).Merge(branches[i-1]); err != nil {
+			return fmt.Errorf(`Failed to automatically merge branch %v into branch %v: %v
+The following steps are needed before the operation can be retried:
+$ git checkout %v
+$ git merge %v
+# resolve all conflicts
+$ git commit -a
+$ git checkout %v
+# retry the original operation
+`, branches[i], branches[i-1], err, branches[i], branches[i-1], originalBranch)
+		}
+	}
+
+	forceOriginalBranch = false
+	return nil
+}
diff --git a/cmd/jiri/cl_test.go b/cmd/jiri/cl_test.go
new file mode 100644
index 0000000..e92f35b
--- /dev/null
+++ b/cmd/jiri/cl_test.go
@@ -0,0 +1,1033 @@
+// Copyright 2015 The Vanadium 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"
+	"os"
+	"path"
+	"path/filepath"
+	"runtime"
+	"strings"
+	"testing"
+
+	"v.io/jiri/gerrit"
+	"v.io/jiri/gitutil"
+	"v.io/jiri/jiri"
+	"v.io/jiri/jiritest"
+	"v.io/jiri/runutil"
+)
+
+// assertCommitCount asserts that the commit count between two
+// branches matches the expectedCount.
+func assertCommitCount(t *testing.T, jirix *jiri.X, branch, baseBranch string, expectedCount int) {
+	got, err := gitutil.New(jirix.NewSeq()).CountCommits(branch, baseBranch)
+	if err != nil {
+		t.Fatalf("%v", err)
+	}
+	if want := 1; got != want {
+		t.Fatalf("unexpected number of commits: got %v, want %v", got, want)
+	}
+}
+
+// assertFileContent asserts that the content of the given file
+// matches the expected content.
+func assertFileContent(t *testing.T, jirix *jiri.X, file, want string) {
+	got, err := jirix.NewSeq().ReadFile(file)
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+	if string(got) != want {
+		t.Fatalf("unexpected content of file %v: got %v, want %v", file, got, want)
+	}
+}
+
+// assertFilesExist asserts that the files exist.
+func assertFilesExist(t *testing.T, jirix *jiri.X, files []string) {
+	s := jirix.NewSeq()
+	for _, file := range files {
+		if _, err := s.Stat(file); err != nil {
+			if runutil.IsNotExist(err) {
+				t.Fatalf("expected file %v to exist but it did not", file)
+			}
+			t.Fatalf("%v", err)
+		}
+	}
+}
+
+// assertFilesDoNotExist asserts that the files do not exist.
+func assertFilesDoNotExist(t *testing.T, jirix *jiri.X, files []string) {
+	s := jirix.NewSeq()
+	for _, file := range files {
+		if _, err := s.Stat(file); err != nil && !runutil.IsNotExist(err) {
+			t.Fatalf("%v", err)
+		} else if err == nil {
+			t.Fatalf("expected file %v to not exist but it did", file)
+		}
+	}
+}
+
+// assertFilesCommitted asserts that the files exist and are committed
+// in the current branch.
+func assertFilesCommitted(t *testing.T, jirix *jiri.X, files []string) {
+	assertFilesExist(t, jirix, files)
+	for _, file := range files {
+		if !gitutil.New(jirix.NewSeq()).IsFileCommitted(file) {
+			t.Fatalf("expected file %v to be committed but it is not", file)
+		}
+	}
+}
+
+// assertFilesNotCommitted asserts that the files exist and are *not*
+// committed in the current branch.
+func assertFilesNotCommitted(t *testing.T, jirix *jiri.X, files []string) {
+	assertFilesExist(t, jirix, files)
+	for _, file := range files {
+		if gitutil.New(jirix.NewSeq()).IsFileCommitted(file) {
+			t.Fatalf("expected file %v not to be committed but it is", file)
+		}
+	}
+}
+
+// assertFilesPushedToRef asserts that the given files have been
+// pushed to the given remote repository reference.
+func assertFilesPushedToRef(t *testing.T, jirix *jiri.X, repoPath, gerritPath, pushedRef string, files []string) {
+	chdir(t, jirix, gerritPath)
+	assertCommitCount(t, jirix, pushedRef, "master", 1)
+	if err := gitutil.New(jirix.NewSeq()).CheckoutBranch(pushedRef); err != nil {
+		t.Fatalf("%v", err)
+	}
+	assertFilesCommitted(t, jirix, files)
+	chdir(t, jirix, repoPath)
+}
+
+// assertStashSize asserts that the stash size matches the expected
+// size.
+func assertStashSize(t *testing.T, jirix *jiri.X, want int) {
+	got, err := gitutil.New(jirix.NewSeq()).StashSize()
+	if err != nil {
+		t.Fatalf("%v", err)
+	}
+	if got != want {
+		t.Fatalf("unxpected stash size: got %v, want %v", got, want)
+	}
+}
+
+// commitFile commits a file with the specified content into a branch
+func commitFile(t *testing.T, jirix *jiri.X, filename string, content string) {
+	s := jirix.NewSeq()
+	if err := s.WriteFile(filename, []byte(content), 0644).Done(); err != nil {
+		t.Fatalf("%v", err)
+	}
+	commitMessage := "Commit " + filename
+	if err := gitutil.New(jirix.NewSeq()).CommitFile(filename, commitMessage); err != nil {
+		t.Fatalf("%v", err)
+	}
+}
+
+// commitFiles commits the given files into to current branch.
+func commitFiles(t *testing.T, jirix *jiri.X, filenames []string) {
+	// Create and commit the files one at a time.
+	for _, filename := range filenames {
+		content := "This is file " + filename
+		commitFile(t, jirix, filename, content)
+	}
+}
+
+// createRepo creates a new repository with the given prefix.
+func createRepo(t *testing.T, jirix *jiri.X, prefix string) string {
+	s := jirix.NewSeq()
+	repoPath, err := s.TempDir(jirix.Root, "repo-"+prefix)
+	if err != nil {
+		t.Fatalf("TempDir() failed: %v", err)
+	}
+	if err := os.Chmod(repoPath, 0777); err != nil {
+		t.Fatalf("Chmod(%v) failed: %v", repoPath, err)
+	}
+	if err := gitutil.New(jirix.NewSeq()).Init(repoPath); err != nil {
+		t.Fatalf("%v", err)
+	}
+	if err := s.MkdirAll(filepath.Join(repoPath, jiri.ProjectMetaDir), os.FileMode(0755)).Done(); err != nil {
+		t.Fatalf("%v", err)
+	}
+	return repoPath
+}
+
+// Simple commit-msg hook that adds a fake Change Id.
+var commitMsgHook string = `#!/bin/sh
+MSG="$1"
+echo "Change-Id: I0000000000000000000000000000000000000000" >> $MSG
+`
+
+// installCommitMsgHook links the gerrit commit-msg hook into a different repo.
+func installCommitMsgHook(t *testing.T, jirix *jiri.X, repoPath string) {
+	hookLocation := path.Join(repoPath, ".git/hooks/commit-msg")
+	if err := jirix.NewSeq().WriteFile(hookLocation, []byte(commitMsgHook), 0755).Done(); err != nil {
+		t.Fatalf("WriteFile(%v) failed: %v", hookLocation, err)
+	}
+}
+
+// chdir changes the runtime working directory and traps any errors.
+func chdir(t *testing.T, jirix *jiri.X, path string) {
+	if err := jirix.NewSeq().Chdir(path).Done(); err != nil {
+		_, file, line, _ := runtime.Caller(1)
+		t.Fatalf("%s: %d: Chdir(%v) failed: %v", file, line, path, err)
+	}
+}
+
+// createRepoFromOrigin creates a Git repo tracking origin/master.
+func createRepoFromOrigin(t *testing.T, jirix *jiri.X, subpath string, originPath string) string {
+	repoPath := createRepo(t, jirix, subpath)
+	chdir(t, jirix, repoPath)
+	if err := gitutil.New(jirix.NewSeq()).AddRemote("origin", originPath); err != nil {
+		t.Fatalf("%v", err)
+	}
+	if err := gitutil.New(jirix.NewSeq()).Pull("origin", "master"); err != nil {
+		t.Fatalf("%v", err)
+	}
+	return repoPath
+}
+
+// createTestRepos sets up three local repositories: origin, gerrit,
+// and the main test repository which pulls from origin and can push
+// to gerrit.
+func createTestRepos(t *testing.T, jirix *jiri.X) (string, string, string) {
+	// Create origin.
+	originPath := createRepo(t, jirix, "origin")
+	chdir(t, jirix, originPath)
+	if err := gitutil.New(jirix.NewSeq()).CommitWithMessage("initial commit"); err != nil {
+		t.Fatalf("%v", err)
+	}
+	// Create test repo.
+	repoPath := createRepoFromOrigin(t, jirix, "test", originPath)
+	// Add Gerrit remote.
+	gerritPath := createRepoFromOrigin(t, jirix, "gerrit", originPath)
+	// Switch back to test repo.
+	chdir(t, jirix, repoPath)
+	return repoPath, originPath, gerritPath
+}
+
+// submit mocks a Gerrit review submit by pushing the Gerrit remote to origin.
+// Actually origin pulls from Gerrit since origin isn't actually a bare git repo.
+// Some of our tests actually rely on accessing .git in origin, so it must be non-bare.
+func submit(t *testing.T, jirix *jiri.X, originPath string, gerritPath string, review *review) {
+	cwd, err := os.Getwd()
+	if err != nil {
+		t.Fatalf("Getwd() failed: %v", err)
+	}
+	chdir(t, jirix, originPath)
+	expectedRef := gerrit.Reference(review.CLOpts)
+	if err := gitutil.New(jirix.NewSeq()).Pull(gerritPath, expectedRef); err != nil {
+		t.Fatalf("Pull gerrit to origin failed: %v", err)
+	}
+	chdir(t, jirix, cwd)
+}
+
+// setupTest creates a setup for testing the review tool.
+func setupTest(t *testing.T, installHook bool) (fake *jiritest.FakeJiriRoot, repoPath, originPath, gerritPath string, cleanup func()) {
+	oldWD, err := os.Getwd()
+	if err != nil {
+		t.Fatalf("Getwd() failed: %v", err)
+	}
+	var cleanupFake func()
+	if fake, cleanupFake = jiritest.NewFakeJiriRoot(t); err != nil {
+		t.Fatalf("%v", err)
+	}
+	repoPath, originPath, gerritPath = createTestRepos(t, fake.X)
+	if installHook == true {
+		for _, path := range []string{repoPath, originPath, gerritPath} {
+			installCommitMsgHook(t, fake.X, path)
+		}
+	}
+	chdir(t, fake.X, repoPath)
+	cleanup = func() {
+		chdir(t, fake.X, oldWD)
+		cleanupFake()
+	}
+	return
+}
+
+func createCLWithFiles(t *testing.T, jirix *jiri.X, branch string, files ...string) {
+	if err := newCL(jirix, []string{branch}); err != nil {
+		t.Fatalf("%v", err)
+	}
+	commitFiles(t, jirix, files)
+}
+
+// TestCleanupClean checks that cleanup succeeds if the branch to be
+// cleaned up has been merged with the master.
+func TestCleanupClean(t *testing.T) {
+	fake, repoPath, originPath, _, cleanup := setupTest(t, true)
+	defer cleanup()
+	branch := "my-branch"
+	if err := gitutil.New(fake.X.NewSeq()).CreateAndCheckoutBranch(branch); err != nil {
+		t.Fatalf("%v", err)
+	}
+	commitFiles(t, fake.X, []string{"file1", "file2"})
+	if err := gitutil.New(fake.X.NewSeq()).CheckoutBranch("master"); err != nil {
+		t.Fatalf("%v", err)
+	}
+	chdir(t, fake.X, originPath)
+	commitFiles(t, fake.X, []string{"file1", "file2"})
+	chdir(t, fake.X, repoPath)
+	if err := cleanupCL(fake.X, []string{branch}); err != nil {
+		t.Fatalf("cleanup() failed: %v", err)
+	}
+	if gitutil.New(fake.X.NewSeq()).BranchExists(branch) {
+		t.Fatalf("cleanup failed to remove the feature branch")
+	}
+}
+
+// TestCleanupDirty checks that cleanup is a no-op if the branch to be
+// cleaned up has unmerged changes.
+func TestCleanupDirty(t *testing.T) {
+	fake, _, _, _, cleanup := setupTest(t, true)
+	defer cleanup()
+	branch := "my-branch"
+	if err := gitutil.New(fake.X.NewSeq()).CreateAndCheckoutBranch(branch); err != nil {
+		t.Fatalf("%v", err)
+	}
+	files := []string{"file1", "file2"}
+	commitFiles(t, fake.X, files)
+	if err := gitutil.New(fake.X.NewSeq()).CheckoutBranch("master"); err != nil {
+		t.Fatalf("%v", err)
+	}
+	if err := cleanupCL(fake.X, []string{branch}); err == nil {
+		t.Fatalf("cleanup did not fail when it should")
+	}
+	if err := gitutil.New(fake.X.NewSeq()).CheckoutBranch(branch); err != nil {
+		t.Fatalf("%v", err)
+	}
+	assertFilesCommitted(t, fake.X, files)
+}
+
+// TestCreateReviewBranch checks that the temporary review branch is
+// created correctly.
+func TestCreateReviewBranch(t *testing.T) {
+	fake, _, _, _, cleanup := setupTest(t, true)
+	defer cleanup()
+	branch := "my-branch"
+	if err := gitutil.New(fake.X.NewSeq()).CreateAndCheckoutBranch(branch); err != nil {
+		t.Fatalf("%v", err)
+	}
+	files := []string{"file1", "file2", "file3"}
+	commitFiles(t, fake.X, files)
+	review, err := newReview(fake.X, gerrit.CLOpts{})
+	if err != nil {
+		t.Fatalf("%v", err)
+	}
+	if expected, got := branch+"-REVIEW", review.reviewBranch; expected != got {
+		t.Fatalf("Unexpected review branch name: expected %v, got %v", expected, got)
+	}
+	commitMessage := "squashed commit"
+	if err := review.createReviewBranch(commitMessage); err != nil {
+		t.Fatalf("%v", err)
+	}
+	// Verify that the branch exists.
+	if !gitutil.New(fake.X.NewSeq()).BranchExists(review.reviewBranch) {
+		t.Fatalf("review branch not found")
+	}
+	if err := gitutil.New(fake.X.NewSeq()).CheckoutBranch(review.reviewBranch); err != nil {
+		t.Fatalf("%v", err)
+	}
+	assertCommitCount(t, fake.X, review.reviewBranch, "master", 1)
+	assertFilesCommitted(t, fake.X, files)
+}
+
+// TestCreateReviewBranchWithEmptyChange checks that running
+// createReviewBranch() on a branch with no changes will result in an
+// EmptyChangeError.
+func TestCreateReviewBranchWithEmptyChange(t *testing.T) {
+	fake, _, _, _, cleanup := setupTest(t, true)
+	defer cleanup()
+	branch := "my-branch"
+	if err := gitutil.New(fake.X.NewSeq()).CreateAndCheckoutBranch(branch); err != nil {
+		t.Fatalf("%v", err)
+	}
+	review, err := newReview(fake.X, gerrit.CLOpts{Remote: branch})
+	if err != nil {
+		t.Fatalf("%v", err)
+	}
+	commitMessage := "squashed commit"
+	err = review.createReviewBranch(commitMessage)
+	if err == nil {
+		t.Fatalf("creating a review did not fail when it should")
+	}
+	if _, ok := err.(emptyChangeError); !ok {
+		t.Fatalf("unexpected error type: %v", err)
+	}
+}
+
+// TestSendReview checks the various options for sending a review.
+func TestSendReview(t *testing.T) {
+	fake, repoPath, _, gerritPath, cleanup := setupTest(t, true)
+	defer cleanup()
+	branch := "my-branch"
+	if err := gitutil.New(fake.X.NewSeq()).CreateAndCheckoutBranch(branch); err != nil {
+		t.Fatalf("%v", err)
+	}
+	files := []string{"file1"}
+	commitFiles(t, fake.X, files)
+	{
+		// Test with draft = false, no reviewiers, and no ccs.
+		review, err := newReview(fake.X, gerrit.CLOpts{Remote: gerritPath})
+		if err != nil {
+			t.Fatalf("%v", err)
+		}
+		if err := review.send(); err != nil {
+			t.Fatalf("failed to send a review: %v", err)
+		}
+		expectedRef := gerrit.Reference(review.CLOpts)
+		assertFilesPushedToRef(t, fake.X, repoPath, gerritPath, expectedRef, files)
+	}
+	{
+		// Test with draft = true, no reviewers, and no ccs.
+		review, err := newReview(fake.X, gerrit.CLOpts{
+			Draft:  true,
+			Remote: gerritPath,
+		})
+		if err != nil {
+			t.Fatalf("%v", err)
+		}
+		if err := review.send(); err != nil {
+			t.Fatalf("failed to send a review: %v", err)
+		}
+		expectedRef := gerrit.Reference(review.CLOpts)
+		assertFilesPushedToRef(t, fake.X, repoPath, gerritPath, expectedRef, files)
+	}
+	{
+		// Test with draft = false, reviewers, and no ccs.
+		review, err := newReview(fake.X, gerrit.CLOpts{
+			Remote:    gerritPath,
+			Reviewers: parseEmails("reviewer1,reviewer2@example.org"),
+		})
+		if err != nil {
+			t.Fatalf("%v", err)
+		}
+		if err := review.send(); err != nil {
+			t.Fatalf("failed to send a review: %v", err)
+		}
+		expectedRef := gerrit.Reference(review.CLOpts)
+		assertFilesPushedToRef(t, fake.X, repoPath, gerritPath, expectedRef, files)
+	}
+	{
+		// Test with draft = true, reviewers, and ccs.
+		review, err := newReview(fake.X, gerrit.CLOpts{
+			Ccs:       parseEmails("cc1@example.org,cc2"),
+			Draft:     true,
+			Remote:    gerritPath,
+			Reviewers: parseEmails("reviewer3@example.org,reviewer4"),
+		})
+		if err != nil {
+			t.Fatalf("%v", err)
+		}
+		if err := review.send(); err != nil {
+			t.Fatalf("failed to send a review: %v", err)
+		}
+		expectedRef := gerrit.Reference(review.CLOpts)
+		assertFilesPushedToRef(t, fake.X, repoPath, gerritPath, expectedRef, files)
+	}
+}
+
+// TestSendReviewNoChangeID checks that review.send() correctly errors when
+// not run with a commit hook that adds a Change-Id.
+func TestSendReviewNoChangeID(t *testing.T) {
+	// Pass 'false' to setup so it doesn't install the commit-msg hook.
+	fake, _, _, gerritPath, cleanup := setupTest(t, false)
+	defer cleanup()
+	branch := "my-branch"
+	if err := gitutil.New(fake.X.NewSeq()).CreateAndCheckoutBranch(branch); err != nil {
+		t.Fatalf("%v", err)
+	}
+	commitFiles(t, fake.X, []string{"file1"})
+	review, err := newReview(fake.X, gerrit.CLOpts{Remote: gerritPath})
+	if err != nil {
+		t.Fatalf("%v", err)
+	}
+	err = review.send()
+	if err == nil {
+		t.Fatalf("sending a review did not fail when it should")
+	}
+	if _, ok := err.(noChangeIDError); !ok {
+		t.Fatalf("unexpected error type: %v", err)
+	}
+}
+
+// TestEndToEnd checks the end-to-end functionality of the review tool.
+func TestEndToEnd(t *testing.T) {
+	fake, repoPath, _, gerritPath, cleanup := setupTest(t, true)
+	defer cleanup()
+	branch := "my-branch"
+	if err := gitutil.New(fake.X.NewSeq()).CreateAndCheckoutBranch(branch); err != nil {
+		t.Fatalf("%v", err)
+	}
+	files := []string{"file1", "file2", "file3"}
+	commitFiles(t, fake.X, files)
+	review, err := newReview(fake.X, gerrit.CLOpts{Remote: gerritPath})
+	if err != nil {
+		t.Fatalf("%v", err)
+	}
+	setTopicFlag = false
+	if err := review.run(); err != nil {
+		t.Fatalf("run() failed: %v", err)
+	}
+	expectedRef := gerrit.Reference(review.CLOpts)
+	assertFilesPushedToRef(t, fake.X, repoPath, gerritPath, expectedRef, files)
+}
+
+// TestLabelsInCommitMessage checks the labels are correctly processed
+// for the commit message.
+//
+// HACK ALERT: This test runs the review.run() function multiple
+// times. The function ends up pushing a commit to a fake "gerrit"
+// repository created by the setupTest() function. For the real gerrit
+// repository, it is possible to push to the refs/for/change reference
+// multiple times, because it is a special reference that "maps"
+// incoming commits to CL branches based on the commit message
+// Change-Id. The fake "gerrit" repository does not implement this
+// logic and thus the same reference cannot be pushed to multiple
+// times. To overcome this obstacle, the test takes advantage of the
+// fact that the reference name is a function of the reviewers and
+// uses different reviewers for different review runs.
+func TestLabelsInCommitMessage(t *testing.T) {
+	fake, repoPath, _, gerritPath, cleanup := setupTest(t, true)
+	defer cleanup()
+	s := fake.X.NewSeq()
+	branch := "my-branch"
+	if err := gitutil.New(fake.X.NewSeq()).CreateAndCheckoutBranch(branch); err != nil {
+		t.Fatalf("%v", err)
+	}
+
+	// Test setting -presubmit=none and autosubmit.
+	files := []string{"file1", "file2", "file3"}
+	commitFiles(t, fake.X, files)
+	review, err := newReview(fake.X, gerrit.CLOpts{
+		Autosubmit: true,
+		Presubmit:  gerrit.PresubmitTestTypeNone,
+		Remote:     gerritPath,
+		Reviewers:  parseEmails("run1"),
+	})
+	if err != nil {
+		t.Fatalf("%v", err)
+	}
+	setTopicFlag = false
+	if err := review.run(); err != nil {
+		t.Fatalf("%v", err)
+	}
+	expectedRef := gerrit.Reference(review.CLOpts)
+	assertFilesPushedToRef(t, fake.X, repoPath, gerritPath, expectedRef, files)
+	// The last three lines of the gerrit commit message file should be:
+	// AutoSubmit
+	// PresubmitTest: none
+	// Change-Id: ...
+	file, err := getCommitMessageFileName(review.jirix, review.CLOpts.Branch)
+	if err != nil {
+		t.Fatalf("%v", err)
+	}
+	bytes, err := s.ReadFile(file)
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+	content := string(bytes)
+	lines := strings.Split(content, "\n")
+	// Make sure the Change-Id line is the last line.
+	if got := lines[len(lines)-1]; !strings.HasPrefix(got, "Change-Id") {
+		t.Fatalf("no Change-Id line found: %s", got)
+	}
+	// Make sure the "AutoSubmit" label exists.
+	if autosubmitLabelRE.FindString(content) == "" {
+		t.Fatalf("AutoSubmit label doesn't exist in the commit message: %s", content)
+	}
+	// Make sure the "PresubmitTest" label exists.
+	if presubmitTestLabelRE.FindString(content) == "" {
+		t.Fatalf("PresubmitTest label doesn't exist in the commit message: %s", content)
+	}
+
+	// Test setting -presubmit=all but keep autosubmit=true.
+	review, err = newReview(fake.X, gerrit.CLOpts{
+		Autosubmit: true,
+		Remote:     gerritPath,
+		Reviewers:  parseEmails("run2"),
+	})
+	if err != nil {
+		t.Fatalf("%v", err)
+	}
+	if err := review.run(); err != nil {
+		t.Fatalf("%v", err)
+	}
+	expectedRef = gerrit.Reference(review.CLOpts)
+	assertFilesPushedToRef(t, fake.X, repoPath, gerritPath, expectedRef, files)
+	bytes, err = s.ReadFile(file)
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+	content = string(bytes)
+	// Make sure there is no PresubmitTest=none any more.
+	match := presubmitTestLabelRE.FindString(content)
+	if match != "" {
+		t.Fatalf("want no presubmit label line, got: %s", match)
+	}
+	// Make sure the "AutoSubmit" label still exists.
+	if autosubmitLabelRE.FindString(content) == "" {
+		t.Fatalf("AutoSubmit label doesn't exist in the commit message: %s", content)
+	}
+
+	// Test setting autosubmit=false.
+	review, err = newReview(fake.X, gerrit.CLOpts{
+		Remote:    gerritPath,
+		Reviewers: parseEmails("run3"),
+	})
+	if err != nil {
+		t.Fatalf("%v", err)
+	}
+	if err := review.run(); err != nil {
+		t.Fatalf("%v", err)
+	}
+	expectedRef = gerrit.Reference(review.CLOpts)
+	assertFilesPushedToRef(t, fake.X, repoPath, gerritPath, expectedRef, files)
+	bytes, err = s.ReadFile(file)
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+	content = string(bytes)
+	// Make sure there is no AutoSubmit label any more.
+	match = autosubmitLabelRE.FindString(content)
+	if match != "" {
+		t.Fatalf("want no AutoSubmit label line, got: %s", match)
+	}
+}
+
+// TestDirtyBranch checks that the tool correctly handles unstaged and
+// untracked changes in a working branch with stashed changes.
+func TestDirtyBranch(t *testing.T) {
+	fake, _, _, gerritPath, cleanup := setupTest(t, true)
+	defer cleanup()
+	s := fake.X.NewSeq()
+	branch := "my-branch"
+	if err := gitutil.New(fake.X.NewSeq()).CreateAndCheckoutBranch(branch); err != nil {
+		t.Fatalf("%v", err)
+	}
+	files := []string{"file1", "file2"}
+	commitFiles(t, fake.X, files)
+	assertStashSize(t, fake.X, 0)
+	stashedFile, stashedFileContent := "stashed-file", "stashed-file content"
+	if err := s.WriteFile(stashedFile, []byte(stashedFileContent), 0644).Done(); err != nil {
+		t.Fatalf("WriteFile(%v, %v) failed: %v", stashedFile, stashedFileContent, err)
+	}
+	if err := gitutil.New(fake.X.NewSeq()).Add(stashedFile); err != nil {
+		t.Fatalf("%v", err)
+	}
+	if _, err := gitutil.New(fake.X.NewSeq()).Stash(); err != nil {
+		t.Fatalf("%v", err)
+	}
+	assertStashSize(t, fake.X, 1)
+	modifiedFile, modifiedFileContent := "file1", "modified-file content"
+	if err := s.WriteFile(modifiedFile, []byte(modifiedFileContent), 0644).Done(); err != nil {
+		t.Fatalf("WriteFile(%v, %v) failed: %v", modifiedFile, modifiedFileContent, err)
+	}
+	stagedFile, stagedFileContent := "file2", "staged-file content"
+	if err := s.WriteFile(stagedFile, []byte(stagedFileContent), 0644).Done(); err != nil {
+		t.Fatalf("WriteFile(%v, %v) failed: %v", stagedFile, stagedFileContent, err)
+	}
+	if err := gitutil.New(fake.X.NewSeq()).Add(stagedFile); err != nil {
+		t.Fatalf("%v", err)
+	}
+	untrackedFile, untrackedFileContent := "file3", "untracked-file content"
+	if err := s.WriteFile(untrackedFile, []byte(untrackedFileContent), 0644).Done(); err != nil {
+		t.Fatalf("WriteFile(%v, %v) failed: %v", untrackedFile, untrackedFileContent, err)
+	}
+	review, err := newReview(fake.X, gerrit.CLOpts{Remote: gerritPath})
+	if err != nil {
+		t.Fatalf("%v", err)
+	}
+	setTopicFlag = false
+	if err := review.run(); err == nil {
+		t.Fatalf("run() didn't fail when it should")
+	}
+	assertFilesNotCommitted(t, fake.X, []string{stagedFile})
+	assertFilesNotCommitted(t, fake.X, []string{untrackedFile})
+	assertFileContent(t, fake.X, modifiedFile, modifiedFileContent)
+	assertFileContent(t, fake.X, stagedFile, stagedFileContent)
+	assertFileContent(t, fake.X, untrackedFile, untrackedFileContent)
+	// As of git 2.4.3 "git stash pop" fails if there are uncommitted
+	// changes in the index. So we need to commit them first.
+	if err := gitutil.New(fake.X.NewSeq()).Commit(); err != nil {
+		t.Fatalf("%v", err)
+	}
+	assertStashSize(t, fake.X, 1)
+	if err := gitutil.New(fake.X.NewSeq()).StashPop(); err != nil {
+		t.Fatalf("%v", err)
+	}
+	assertStashSize(t, fake.X, 0)
+	assertFilesNotCommitted(t, fake.X, []string{stashedFile})
+	assertFileContent(t, fake.X, stashedFile, stashedFileContent)
+}
+
+// TestRunInSubdirectory checks that the command will succeed when run from
+// within a subdirectory of a branch that does not exist on master branch, and
+// will return the user to the subdirectory after completion.
+func TestRunInSubdirectory(t *testing.T) {
+	fake, repoPath, _, gerritPath, cleanup := setupTest(t, true)
+	defer cleanup()
+	s := fake.X.NewSeq()
+	branch := "my-branch"
+	if err := gitutil.New(fake.X.NewSeq()).CreateAndCheckoutBranch(branch); err != nil {
+		t.Fatalf("%v", err)
+	}
+	subdir := "sub/directory"
+	subdirPerms := os.FileMode(0744)
+	if err := s.MkdirAll(subdir, subdirPerms).Done(); err != nil {
+		t.Fatalf("MkdirAll(%v, %v) failed: %v", subdir, subdirPerms, err)
+	}
+	files := []string{path.Join(subdir, "file1")}
+	commitFiles(t, fake.X, files)
+	chdir(t, fake.X, subdir)
+	review, err := newReview(fake.X, gerrit.CLOpts{Remote: gerritPath})
+	if err != nil {
+		t.Fatalf("%v", err)
+	}
+	setTopicFlag = false
+	if err := review.run(); err != nil {
+		t.Fatalf("run() failed: %v", err)
+	}
+	path := path.Join(repoPath, subdir)
+	want, err := filepath.EvalSymlinks(path)
+	if err != nil {
+		t.Fatalf("EvalSymlinks(%v) failed: %v", path, err)
+	}
+	cwd, err := os.Getwd()
+	if err != nil {
+		t.Fatalf("%v", err)
+	}
+	got, err := filepath.EvalSymlinks(cwd)
+	if err != nil {
+		t.Fatalf("EvalSymlinks(%v) failed: %v", cwd, err)
+	}
+	if got != want {
+		t.Fatalf("unexpected working directory: got %v, want %v", got, want)
+	}
+	expectedRef := gerrit.Reference(review.CLOpts)
+	assertFilesPushedToRef(t, fake.X, repoPath, gerritPath, expectedRef, files)
+}
+
+// TestProcessLabels checks that the processLabels function works as expected.
+func TestProcessLabels(t *testing.T) {
+	fake, _, _, _, cleanup := setupTest(t, true)
+	defer cleanup()
+	testCases := []struct {
+		autosubmit      bool
+		presubmitType   gerrit.PresubmitTestType
+		originalMessage string
+		expectedMessage string
+	}{
+		{
+			presubmitType:   gerrit.PresubmitTestTypeNone,
+			originalMessage: "",
+			expectedMessage: "PresubmitTest: none\n",
+		},
+		{
+			autosubmit:      true,
+			presubmitType:   gerrit.PresubmitTestTypeNone,
+			originalMessage: "",
+			expectedMessage: "AutoSubmit\nPresubmitTest: none\n",
+		},
+		{
+			presubmitType:   gerrit.PresubmitTestTypeNone,
+			originalMessage: "review message\n",
+			expectedMessage: "review message\nPresubmitTest: none\n",
+		},
+		{
+			autosubmit:      true,
+			presubmitType:   gerrit.PresubmitTestTypeNone,
+			originalMessage: "review message\n",
+			expectedMessage: "review message\nAutoSubmit\nPresubmitTest: none\n",
+		},
+		{
+			presubmitType: gerrit.PresubmitTestTypeNone,
+			originalMessage: `review message
+
+Change-Id: I0000000000000000000000000000000000000000`,
+			expectedMessage: `review message
+
+PresubmitTest: none
+Change-Id: I0000000000000000000000000000000000000000`,
+		},
+		{
+			autosubmit:    true,
+			presubmitType: gerrit.PresubmitTestTypeNone,
+			originalMessage: `review message
+
+Change-Id: I0000000000000000000000000000000000000000`,
+			expectedMessage: `review message
+
+AutoSubmit
+PresubmitTest: none
+Change-Id: I0000000000000000000000000000000000000000`,
+		},
+		{
+			presubmitType:   gerrit.PresubmitTestTypeAll,
+			originalMessage: "",
+			expectedMessage: "",
+		},
+		{
+			presubmitType:   gerrit.PresubmitTestTypeAll,
+			originalMessage: "review message\n",
+			expectedMessage: "review message\n",
+		},
+		{
+			presubmitType: gerrit.PresubmitTestTypeAll,
+			originalMessage: `review message
+
+Change-Id: I0000000000000000000000000000000000000000`,
+			expectedMessage: `review message
+
+Change-Id: I0000000000000000000000000000000000000000`,
+		},
+	}
+	for _, test := range testCases {
+		review, err := newReview(fake.X, gerrit.CLOpts{
+			Autosubmit: test.autosubmit,
+			Presubmit:  test.presubmitType,
+		})
+		if err != nil {
+			t.Fatalf("%v", err)
+		}
+		if got := review.processLabels(test.originalMessage); got != test.expectedMessage {
+			t.Fatalf("want %s, got %s", test.expectedMessage, got)
+		}
+	}
+}
+
+// TestCLNew checks the operation of the "jiri cl new" command.
+func TestCLNew(t *testing.T) {
+	fake, _, _, _, cleanup := setupTest(t, true)
+	defer cleanup()
+
+	// Create some dependent CLs.
+	if err := newCL(fake.X, []string{"feature1"}); err != nil {
+		t.Fatalf("%v", err)
+	}
+	if err := newCL(fake.X, []string{"feature2"}); err != nil {
+		t.Fatalf("%v", err)
+	}
+
+	// Check that their dependency paths have been recorded correctly.
+	testCases := []struct {
+		branch string
+		data   []byte
+	}{
+		{
+			branch: "feature1",
+			data:   []byte("master"),
+		},
+		{
+			branch: "feature2",
+			data:   []byte("master\nfeature1"),
+		},
+	}
+	s := fake.X.NewSeq()
+	for _, testCase := range testCases {
+		file, err := getDependencyPathFileName(fake.X, testCase.branch)
+		if err != nil {
+			t.Fatalf("%v", err)
+		}
+		data, err := s.ReadFile(file)
+		if err != nil {
+			t.Fatalf("%v", err)
+		}
+		if bytes.Compare(data, testCase.data) != 0 {
+			t.Fatalf("unexpected data:\ngot\n%v\nwant\n%v", string(data), string(testCase.data))
+		}
+	}
+}
+
+// TestDependentClsWithEditDelete exercises a previously observed failure case
+// where if a CL edits a file and a dependent CL deletes it, jiri cl mail after
+// the deletion failed with unrecoverable merge errors.
+func TestDependentClsWithEditDelete(t *testing.T) {
+	fake, repoPath, originPath, gerritPath, cleanup := setupTest(t, true)
+	defer cleanup()
+	chdir(t, fake.X, originPath)
+	commitFiles(t, fake.X, []string{"A", "B"})
+
+	chdir(t, fake.X, repoPath)
+	if err := syncCL(fake.X); err != nil {
+		t.Fatalf("%v", err)
+	}
+	assertFilesExist(t, fake.X, []string{"A", "B"})
+
+	createCLWithFiles(t, fake.X, "editme", "C")
+	if err := fake.X.NewSeq().WriteFile("B", []byte("Will I dream?"), 0644).Done(); err != nil {
+		t.Fatalf("%v", err)
+	}
+	if err := gitutil.New(fake.X.NewSeq()).Add("B"); err != nil {
+		t.Fatalf("%v", err)
+	}
+	if err := gitutil.New(fake.X.NewSeq()).CommitWithMessage("editing stuff"); err != nil {
+		t.Fatalf("git commit failed: %v", err)
+	}
+	review, err := newReview(fake.X, gerrit.CLOpts{
+		Remote:    gerritPath,
+		Reviewers: parseEmails("run1"), // See hack note about TestLabelsInCommitMessage
+	})
+	if err != nil {
+		t.Fatalf("%v", err)
+	}
+	setTopicFlag = false
+	if err := review.run(); err != nil {
+		t.Fatalf("run() failed: %v", err)
+	}
+
+	if err := newCL(fake.X, []string{"deleteme"}); err != nil {
+		t.Fatalf("%v", err)
+	}
+	if err := gitutil.New(fake.X.NewSeq()).Remove("B", "C"); err != nil {
+		t.Fatalf("git rm B C failed: %v", err)
+	}
+	if err := gitutil.New(fake.X.NewSeq()).CommitWithMessage("deleting stuff"); err != nil {
+		t.Fatalf("git commit failed: %v", err)
+	}
+	review, err = newReview(fake.X, gerrit.CLOpts{
+		Remote:    gerritPath,
+		Reviewers: parseEmails("run2"),
+	})
+	if err != nil {
+		t.Fatalf("%v", err)
+	}
+	if err := review.run(); err != nil {
+		t.Fatalf("run() failed: %v", err)
+	}
+
+	chdir(t, fake.X, gerritPath)
+	expectedRef := gerrit.Reference(review.CLOpts)
+	if err := gitutil.New(fake.X.NewSeq()).CheckoutBranch(expectedRef); err != nil {
+		t.Fatalf("%v", err)
+	}
+	assertFilesExist(t, fake.X, []string{"A"})
+	assertFilesDoNotExist(t, fake.X, []string{"B", "C"})
+}
+
+// TestParallelDev checks "jiri cl mail" behavior when parallel development has
+// been submitted upstream.
+func TestParallelDev(t *testing.T) {
+	fake, repoPath, originPath, gerritAPath, cleanup := setupTest(t, true)
+	defer cleanup()
+	gerritBPath := createRepoFromOrigin(t, fake.X, "gerritB", originPath)
+	chdir(t, fake.X, repoPath)
+
+	// Create parallel branches with:
+	// * non-conflicting changes in different files
+	// * conflicting changes in a file
+	createCLWithFiles(t, fake.X, "feature1-A", "A")
+
+	if err := gitutil.New(fake.X.NewSeq()).CheckoutBranch("master"); err != nil {
+		t.Fatalf("%v", err)
+	}
+	createCLWithFiles(t, fake.X, "feature1-B", "B")
+	commitFile(t, fake.X, "A", "Don't tread on me.")
+
+	reviewB, err := newReview(fake.X, gerrit.CLOpts{Remote: gerritBPath})
+	if err != nil {
+		t.Fatalf("%v", err)
+	}
+	setTopicFlag = false
+	if err := reviewB.run(); err != nil {
+		t.Fatalf("run() failed: %v", err)
+	}
+
+	// Submit B and verify A doesn't revert it.
+	submit(t, fake.X, originPath, gerritBPath, reviewB)
+
+	// Assert files pushed to origin.
+	chdir(t, fake.X, originPath)
+	assertFilesExist(t, fake.X, []string{"A", "B"})
+	chdir(t, fake.X, repoPath)
+
+	if err := gitutil.New(fake.X.NewSeq()).CheckoutBranch("feature1-A"); err != nil {
+		t.Fatalf("%v", err)
+	}
+
+	reviewA, err := newReview(fake.X, gerrit.CLOpts{Remote: gerritAPath})
+	if err == nil {
+		t.Fatalf("creating a review did not fail when it should")
+	}
+	// Assert state restored after failed review.
+	assertFileContent(t, fake.X, "A", "This is file A")
+	assertFilesDoNotExist(t, fake.X, []string{"B"})
+
+	// Manual conflict resolution.
+	if err := gitutil.New(fake.X.NewSeq()).Merge("master", gitutil.ResetOnFailureOpt(false)); err == nil {
+		t.Fatalf("merge applied cleanly when it shouldn't")
+	}
+	assertFilesNotCommitted(t, fake.X, []string{"A", "B"})
+	assertFileContent(t, fake.X, "B", "This is file B")
+
+	if err := fake.X.NewSeq().WriteFile("A", []byte("This is file A. Don't tread on me."), 0644).Done(); err != nil {
+		t.Fatalf("%v", err)
+	}
+
+	if err := gitutil.New(fake.X.NewSeq()).Add("A"); err != nil {
+		t.Fatalf("%v", err)
+	}
+	if err := gitutil.New(fake.X.NewSeq()).Add("B"); err != nil {
+		t.Fatalf("%v", err)
+	}
+	if err := gitutil.New(fake.X.NewSeq()).CommitWithMessage("Conflict resolution"); err != nil {
+		t.Fatalf("%v", err)
+	}
+
+	// Retry review.
+	reviewA, err = newReview(fake.X, gerrit.CLOpts{Remote: gerritAPath})
+	if err != nil {
+		t.Fatalf("review failed: %v", err)
+	}
+
+	if err := reviewA.run(); err != nil {
+		t.Fatalf("run() failed: %v", err)
+	}
+
+	chdir(t, fake.X, gerritAPath)
+	expectedRef := gerrit.Reference(reviewA.CLOpts)
+	if err := gitutil.New(fake.X.NewSeq()).CheckoutBranch(expectedRef); err != nil {
+		t.Fatalf("%v", err)
+	}
+	assertFilesExist(t, fake.X, []string{"B"})
+}
+
+// TestCLSync checks the operation of the "jiri cl sync" command.
+func TestCLSync(t *testing.T) {
+	fake, _, _, _, cleanup := setupTest(t, true)
+	defer cleanup()
+
+	// Create some dependent CLs.
+	if err := newCL(fake.X, []string{"feature1"}); err != nil {
+		t.Fatalf("%v", err)
+	}
+	if err := newCL(fake.X, []string{"feature2"}); err != nil {
+		t.Fatalf("%v", err)
+	}
+
+	// Add the "test" file to the master.
+	if err := gitutil.New(fake.X.NewSeq()).CheckoutBranch("master"); err != nil {
+		t.Fatalf("%v", err)
+	}
+	commitFiles(t, fake.X, []string{"test"})
+
+	// Sync the dependent CLs.
+	if err := gitutil.New(fake.X.NewSeq()).CheckoutBranch("feature2"); err != nil {
+		t.Fatalf("%v", err)
+	}
+	if err := syncCL(fake.X); err != nil {
+		t.Fatalf("%v", err)
+	}
+
+	// Check that the "test" file exists in the dependent CLs.
+	for _, branch := range []string{"feature1", "feature2"} {
+		if err := gitutil.New(fake.X.NewSeq()).CheckoutBranch(branch); err != nil {
+			t.Fatalf("%v", err)
+		}
+		assertFilesExist(t, fake.X, []string{"test"})
+	}
+}
diff --git a/cmd/jiri/cmd.go b/cmd/jiri/cmd.go
new file mode 100644
index 0000000..0d4d2ee
--- /dev/null
+++ b/cmd/jiri/cmd.go
@@ -0,0 +1,158 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+// The following enables go generate to generate the doc.go file.
+//go:generate go run $JIRI_ROOT/release/go/src/v.io/x/lib/cmdline/testdata/gendoc.go -env="" .
+
+package main
+
+import (
+	"runtime"
+
+	"v.io/jiri/tool"
+	"v.io/x/lib/cmdline"
+)
+
+func init() {
+	runtime.GOMAXPROCS(runtime.NumCPU())
+
+	tool.InitializeRunFlags(&cmdRoot.Flags)
+}
+
+func main() {
+	cmdline.Main(cmdRoot)
+}
+
+// cmdRoot represents the root of the jiri tool.
+var cmdRoot = &cmdline.Command{
+	Name:  "jiri",
+	Short: "Multi-purpose tool for multi-repo development",
+	Long: `
+Command jiri is a multi-purpose tool for multi-repo development.
+`,
+	LookPath: true,
+	Children: []*cmdline.Command{
+		cmdCL,
+		cmdContributors,
+		cmdImport,
+		cmdProfile,
+		cmdProject,
+		cmdRebuild,
+		cmdSnapshot,
+		cmdUpdate,
+		cmdUpgrade,
+		cmdWhich,
+	},
+	Topics: []cmdline.Topic{
+		topicFileSystem,
+		topicManifest,
+	},
+}
+
+var topicFileSystem = cmdline.Topic{
+	Name:  "filesystem",
+	Short: "Description of jiri file system layout",
+	Long: `
+All data managed by the jiri tool is located in the file system under a root
+directory, colloquially called the jiri root directory.  The file system layout
+looks like this:
+
+ [root]                              # root directory (name picked by user)
+ [root]/.jiri_root                   # root metadata directory
+ [root]/.jiri_root/bin               # contains tool binaries (jiri, etc.)
+ [root]/.jiri_root/update_history    # contains history of update snapshots
+ [root]/.manifest                    # contains jiri manifests
+ [root]/[project1]                   # project directory (name picked by user)
+ [root]/[project1]/.jiri             # project metadata directory
+ [root]/[project1]/.jiri/metadata.v2 # project metadata file
+ [root]/[project1]/.jiri/<<cls>>     # project per-cl metadata directories
+ [root]/[project1]/<<files>>         # project files
+ [root]/[project2]...
+
+The [root] and [projectN] directory names are picked by the user.  The <<cls>>
+are named via jiri cl new, and the <<files>> are named as the user adds files
+and directories to their project.  All other names above have special meaning to
+the jiri tool, and cannot be changed; you must ensure your path names don't
+collide with these special names.
+
+There are two ways to run the jiri tool:
+
+1) Shim script (recommended approach).  This is a shell script that looks for
+the [root] directory.  If the JIRI_ROOT environment variable is set, that is
+assumed to be the [root] directory.  Otherwise the script looks for the
+.jiri_root directory, starting in the current working directory and walking up
+the directory chain.  The search is terminated successfully when the .jiri_root
+directory is found; it fails after it reaches the root of the file system.  Thus
+the shim must be invoked from the [root] directory or one of its subdirectories.
+
+Once the [root] is found, the JIRI_ROOT environment variable is set to its
+location, and [root]/.jiri_root/bin/jiri is invoked.  That file contains the
+actual jiri binary.
+
+The point of the shim script is to make it easy to use the jiri tool with
+multiple [root] directories on your file system.  Keep in mind that when "jiri
+update" is run, the jiri tool itself is automatically updated along with all
+projects.  By using the shim script, you only need to remember to invoke the
+jiri tool from within the appropriate [root] directory, and the projects and
+tools under that [root] directory will be updated.
+
+The shim script is located at [root]/release/go/src/v.io/jiri/scripts/jiri
+
+2) Direct binary.  This is the jiri binary, containing all of the actual jiri
+tool logic.  The binary requires the JIRI_ROOT environment variable to point to
+the [root] directory.
+
+Note that if you have multiple [root] directories on your file system, you must
+remember to run the jiri binary corresponding to the setting of your JIRI_ROOT
+environment variable.  Things may fail if you mix things up, since the jiri
+binary is updated with each call to "jiri update", and you may encounter version
+mismatches between the jiri binary and the various metadata files or other
+logic.  This is the reason the shim script is recommended over running the
+binary directly.
+
+The binary is located at [root]/.jiri_root/bin/jiri
+`,
+}
+
+// TODO(toddw): Update the description of manifest files.
+var topicManifest = cmdline.Topic{
+	Name:  "manifest",
+	Short: "Description of manifest files",
+	Long: `
+Jiri manifests are revisioned and stored in a "manifest" repository, that is
+available locally in $JIRI_ROOT/.manifest. The manifest uses the following XML
+schema:
+
+ <manifest>
+   <imports>
+     <import name="default"/>
+     ...
+   </imports>
+   <projects>
+     <project name="release.go.jiri"
+              path="release/go/src/v.io/jiri"
+              protocol="git"
+              name="https://vanadium.googlesource.com/release.go.jiri"
+              revision="HEAD"/>
+     ...
+   </projects>
+   <tools>
+     <tool name="jiri" package="v.io/jiri"/>
+     ...
+   </tools>
+ </manifest>
+
+The <import> element can be used to share settings across multiple
+manifests. Import names are interpreted relative to the $JIRI_ROOT/.manifest/v2
+directory. Import cycles are not allowed and if a project or a tool is specified
+multiple times, the last specification takes effect. In particular, the elements
+<project name="foo" exclude="true"/> and <tool name="bar" exclude="true"/> can
+be used to exclude previously included projects and tools.
+
+The tool identifies which manifest to use using the following algorithm. If the
+$JIRI_ROOT/.local_manifest file exists, then it is used. Otherwise, the
+$JIRI_ROOT/.manifest/v2/<manifest>.xml file is used, where <manifest> is the
+value of the -manifest command-line flag, which defaults to "default".
+`,
+}
diff --git a/cmd/jiri/contrib.go b/cmd/jiri/contrib.go
new file mode 100644
index 0000000..1fb31e6
--- /dev/null
+++ b/cmd/jiri/contrib.go
@@ -0,0 +1,223 @@
+// Copyright 2015 The Vanadium 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 (
+	"encoding/xml"
+	"fmt"
+	"path/filepath"
+	"regexp"
+	"sort"
+	"strconv"
+	"strings"
+
+	"v.io/jiri/collect"
+	"v.io/jiri/gitutil"
+	"v.io/jiri/jiri"
+	"v.io/jiri/project"
+	"v.io/jiri/tool"
+	"v.io/x/lib/cmdline"
+	"v.io/x/lib/set"
+)
+
+const (
+	aliasesFileName = "aliases.v1.xml"
+)
+
+var (
+	countFlag   bool
+	aliasesFlag string
+)
+
+func init() {
+	cmdContributors.Flags.BoolVar(&countFlag, "n", false, "Show number of contributions.")
+	cmdContributors.Flags.StringVar(&aliasesFlag, "aliases", "", "Path to the aliases file.")
+}
+
+// cmdContributors represents the "jiri contributors" command.
+var cmdContributors = &cmdline.Command{
+	Runner: jiri.RunnerFunc(runContributors),
+	Name:   "contributors",
+	Short:  "List project contributors",
+	Long: `
+Lists project contributors. Projects to consider can be specified as
+an argument. If no projects are specified, all projects in the current
+manifest are considered by default.
+`,
+	ArgsName: "<projects>",
+	ArgsLong: "<projects> is a list of projects to consider.",
+}
+
+type contributor struct {
+	count int
+	email string
+	name  string
+}
+
+var (
+	contributorRE = regexp.MustCompile("^(.*)\t(.*) <(.*)>$")
+)
+
+type aliasesSchema struct {
+	XMLName xml.Name      `xml:"aliases"`
+	Names   []nameSchema  `xml:"name"`
+	Emails  []emailSchema `xml:"email"`
+}
+
+type nameSchema struct {
+	Canonical string   `xml:"canonical"`
+	Aliases   []string `xml:"alias"`
+}
+
+type emailSchema struct {
+	Canonical string   `xml:"canonical"`
+	Aliases   []string `xml:"alias"`
+}
+
+type aliasMaps struct {
+	emails map[string]string
+	names  map[string]string
+}
+
+func canonicalize(aliases *aliasMaps, email, name string) (string, string) {
+	canonicalEmail, canonicalName := email, name
+	if email, ok := aliases.emails[email]; ok {
+		canonicalEmail = email
+	}
+	if name, ok := aliases.names[name]; ok {
+		canonicalName = name
+	}
+	return canonicalEmail, canonicalName
+}
+
+func loadAliases(jirix *jiri.X) (*aliasMaps, error) {
+	aliasesFile := aliasesFlag
+	if aliasesFile == "" {
+		dataDir, err := project.DataDirPath(jirix, tool.Name)
+		if err != nil {
+			return nil, err
+		}
+		aliasesFile = filepath.Join(dataDir, aliasesFileName)
+	}
+	bytes, err := jirix.NewSeq().ReadFile(aliasesFile)
+	if err != nil {
+		return nil, err
+	}
+	var data aliasesSchema
+	if err := xml.Unmarshal(bytes, &data); err != nil {
+		return nil, fmt.Errorf("Unmarshal(%v) failed: %v", string(bytes), err)
+	}
+	aliases := &aliasMaps{
+		emails: map[string]string{},
+		names:  map[string]string{},
+	}
+	for _, email := range data.Emails {
+		for _, alias := range email.Aliases {
+			aliases.emails[alias] = email.Canonical
+		}
+	}
+	for _, name := range data.Names {
+		for _, alias := range name.Aliases {
+			aliases.names[alias] = name.Canonical
+		}
+	}
+	return aliases, nil
+}
+
+func runContributors(jirix *jiri.X, args []string) error {
+	localProjects, err := project.LocalProjects(jirix, project.FastScan)
+	if err != nil {
+		return err
+	}
+	projectNames := map[string]struct{}{}
+	if len(args) != 0 {
+		projectNames = set.String.FromSlice(args)
+	} else {
+		for _, p := range localProjects {
+			projectNames[p.Name] = struct{}{}
+		}
+	}
+
+	aliases, err := loadAliases(jirix)
+	if err != nil {
+		return err
+	}
+	contributors := map[string]*contributor{}
+	for name, _ := range projectNames {
+		projects := localProjects.Find(name)
+		if len(projects) == 0 {
+			continue
+		}
+
+		for _, project := range projects {
+			if err := jirix.NewSeq().Chdir(project.Path).Done(); err != nil {
+				return err
+			}
+			switch project.Protocol {
+			case "git":
+				lines, err := listCommitters(jirix)
+				if err != nil {
+					return err
+				}
+				for _, line := range lines {
+					matches := contributorRE.FindStringSubmatch(line)
+					if got, want := len(matches), 4; got != want {
+						return fmt.Errorf("unexpected length of %v: got %v, want %v", matches, got, want)
+					}
+					count, err := strconv.Atoi(strings.TrimSpace(matches[1]))
+					if err != nil {
+						return fmt.Errorf("Atoi(%v) failed: %v", strings.TrimSpace(matches[1]), err)
+					}
+					c := &contributor{
+						count: count,
+						email: strings.TrimSpace(matches[3]),
+						name:  strings.TrimSpace(matches[2]),
+					}
+					if c.email == "jenkins.veyron@gmail.com" || c.email == "jenkins.veyron.rw@gmail.com" {
+						continue
+					}
+					c.email, c.name = canonicalize(aliases, c.email, c.name)
+					if existing, ok := contributors[c.name]; ok {
+						existing.count += c.count
+					} else {
+						contributors[c.name] = c
+					}
+				}
+			}
+		}
+	}
+	names := []string{}
+	for name, _ := range contributors {
+		names = append(names, name)
+	}
+	sort.Strings(names)
+	for _, name := range names {
+		c := contributors[name]
+		if countFlag {
+			fmt.Fprintf(jirix.Stdout(), "%4d ", c.count)
+		}
+		fmt.Fprintf(jirix.Stdout(), "%v <%v>\n", c.name, c.email)
+	}
+	return nil
+}
+
+func listCommitters(jirix *jiri.X) (_ []string, e error) {
+	branch, err := gitutil.New(jirix.NewSeq()).CurrentBranchName()
+	if err != nil {
+		return nil, err
+	}
+	stashed, err := gitutil.New(jirix.NewSeq()).Stash()
+	if err != nil {
+		return nil, err
+	}
+	if stashed {
+		defer collect.Error(func() error { return gitutil.New(jirix.NewSeq()).StashPop() }, &e)
+	}
+	if err := gitutil.New(jirix.NewSeq()).CheckoutBranch("master"); err != nil {
+		return nil, err
+	}
+	defer collect.Error(func() error { return gitutil.New(jirix.NewSeq()).CheckoutBranch(branch) }, &e)
+	return gitutil.New(jirix.NewSeq()).Committers()
+}
diff --git a/cmd/jiri/doc.go b/cmd/jiri/doc.go
new file mode 100644
index 0000000..bfe847c
--- /dev/null
+++ b/cmd/jiri/doc.go
@@ -0,0 +1,963 @@
+// Copyright 2015 The Vanadium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+// This file was auto-generated via go generate.
+// DO NOT UPDATE MANUALLY
+
+/*
+Command jiri is a multi-purpose tool for multi-repo development.
+
+Usage:
+   jiri [flags] <command>
+
+The jiri commands are:
+   cl           Manage project changelists
+   contributors List project contributors
+   import       Adds imports to .jiri_manifest file
+   profile      Display information about installed profiles
+   project      Manage the jiri projects
+   rebuild      Rebuild all jiri tools
+   snapshot     Manage project snapshots
+   update       Update all jiri tools and projects
+   upgrade      Upgrade jiri to new-style manifests
+   which        Show path to the jiri tool
+   help         Display help for commands or topics
+
+The jiri additional help topics are:
+   filesystem  Description of jiri file system layout
+   manifest    Description of manifest files
+
+The jiri flags are:
+ -color=true
+   Use color to format output.
+ -n=false
+   Show what commands will run but do not execute them.
+ -v=false
+   Print verbose output.
+
+The global flags are:
+ -metadata=<just specify -metadata to activate>
+   Displays metadata for the program and exits.
+ -time=false
+   Dump timing information to stderr before exiting the program.
+
+Jiri cl - Manage project changelists
+
+Manage project changelists.
+
+Usage:
+   jiri cl [flags] <command>
+
+The jiri cl commands are:
+   cleanup     Clean up changelists that have been merged
+   mail        Mail a changelist for review
+   new         Create a new local branch for a changelist
+   sync        Bring a changelist up to date
+
+The jiri cl flags are:
+ -color=true
+   Use color to format output.
+ -n=false
+   Show what commands will run but do not execute them.
+ -v=false
+   Print verbose output.
+
+Jiri cl cleanup - Clean up changelists that have been merged
+
+Command "cleanup" checks that the given branches have been merged into the
+corresponding remote branch. If a branch differs from the corresponding remote
+branch, the command reports the difference and stops. Otherwise, it deletes the
+given branches.
+
+Usage:
+   jiri cl cleanup [flags] <branches>
+
+<branches> is a list of branches to cleanup.
+
+The jiri cl cleanup flags are:
+ -f=false
+   Ignore unmerged changes.
+ -remote-branch=master
+   Name of the remote branch the CL pertains to, without the leading "origin/".
+
+ -color=true
+   Use color to format output.
+ -n=false
+   Show what commands will run but do not execute them.
+ -v=false
+   Print verbose output.
+
+Jiri cl mail - Mail a changelist for review
+
+Command "mail" squashes all commits of a local branch into a single "changelist"
+and mails this changelist to Gerrit as a single commit. First time the command
+is invoked, it generates a Change-Id for the changelist, which is appended to
+the commit message. Consecutive invocations of the command use the same
+Change-Id by default, informing Gerrit that the incomming commit is an update of
+an existing changelist.
+
+Usage:
+   jiri cl mail [flags]
+
+The jiri cl mail flags are:
+ -autosubmit=false
+   Automatically submit the changelist when feasible.
+ -cc=
+   Comma-seperated list of emails or LDAPs to cc.
+ -check-uncommitted=true
+   Check that no uncommitted changes exist.
+ -d=false
+   Send a draft changelist.
+ -edit=true
+   Open an editor to edit the CL description.
+ -host=
+   Gerrit host to use.  Defaults to gerrit host specified in manifest.
+ -m=
+   CL description.
+ -presubmit=all
+   The type of presubmit tests to run. Valid values: none,all.
+ -r=
+   Comma-seperated list of emails or LDAPs to request review.
+ -remote-branch=master
+   Name of the remote branch the CL pertains to, without the leading "origin/".
+ -set-topic=true
+   Set Gerrit CL topic.
+ -topic=
+   CL topic, defaults to <username>-<branchname>.
+ -verify=true
+   Run pre-push git hooks.
+
+ -color=true
+   Use color to format output.
+ -n=false
+   Show what commands will run but do not execute them.
+ -v=false
+   Print verbose output.
+
+Jiri cl new - Create a new local branch for a changelist
+
+Command "new" creates a new local branch for a changelist. In particular, it
+forks a new branch with the given name from the current branch and records the
+relationship between the current branch and the new branch in the .jiri metadata
+directory. The information recorded in the .jiri metadata directory tracks
+dependencies between CLs and is used by the "jiri cl sync" and "jiri cl mail"
+commands.
+
+Usage:
+   jiri cl new [flags] <name>
+
+<name> is the changelist name.
+
+The jiri cl new flags are:
+ -color=true
+   Use color to format output.
+ -n=false
+   Show what commands will run but do not execute them.
+ -v=false
+   Print verbose output.
+
+Jiri cl sync - Bring a changelist up to date
+
+Command "sync" brings the CL identified by the current branch up to date with
+the branch tracking the remote branch this CL pertains to. To do that, the
+command uses the information recorded in the .jiri metadata directory to
+identify the sequence of dependent CLs leading to the current branch. The
+command then iterates over this sequence bringing each of the CLs up to date
+with its ancestor. The end result of this process is that all CLs in the
+sequence are up to date with the branch that tracks the remote branch this CL
+pertains to.
+
+NOTE: It is possible that the command cannot automatically merge changes in an
+ancestor into its dependent. When that occurs, the command is aborted and prints
+instructions that need to be followed before the command can be retried.
+
+Usage:
+   jiri cl sync [flags]
+
+The jiri cl sync flags are:
+ -remote-branch=master
+   Name of the remote branch the CL pertains to, without the leading "origin/".
+
+ -color=true
+   Use color to format output.
+ -n=false
+   Show what commands will run but do not execute them.
+ -v=false
+   Print verbose output.
+
+Jiri contributors - List project contributors
+
+Lists project contributors. Projects to consider can be specified as an
+argument. If no projects are specified, all projects in the current manifest are
+considered by default.
+
+Usage:
+   jiri contributors [flags] <projects>
+
+<projects> is a list of projects to consider.
+
+The jiri contributors flags are:
+ -aliases=
+   Path to the aliases file.
+ -n=false
+   Show number of contributions.
+
+ -color=true
+   Use color to format output.
+ -v=false
+   Print verbose output.
+
+Jiri import
+
+Command "import" adds imports to the $JIRI_ROOT/.jiri_manifest file, which
+specifies manifest information for the jiri tool.  The file is created if it
+doesn't already exist, otherwise additional imports are added to the existing
+file.
+
+An <import> element is added to the manifest representing a remote manifest
+import.  The manifest file path is relative to the root directory of the remote
+import repository.
+
+Example:
+  $ jiri import myfile https://foo.com/bar.git
+
+Run "jiri help manifest" for details on manifests.
+
+Usage:
+   jiri import [flags] <manifest> <remote>
+
+<manifest> specifies the manifest file to use.
+
+<remote> specifies the remote manifest repository.
+
+The jiri import flags are:
+ -name=
+   The name of the remote manifest project, used to disambiguate manifest
+   projects with the same remote.  Typically empty.
+ -out=
+   The output file.  Uses $JIRI_ROOT/.jiri_manifest if unspecified.  Uses stdout
+   if set to "-".
+ -overwrite=false
+   Write a new .jiri_manifest file with the given specification.  If it already
+   exists, the existing content will be ignored and the file will be
+   overwritten.
+ -protocol=git
+   The version control protocol used by the remote manifest project.
+ -remote-branch=master
+   The branch of the remote manifest project to track, without the leading
+   "origin/".
+ -root=
+   Root to store the manifest project locally.
+
+ -color=true
+   Use color to format output.
+ -n=false
+   Show what commands will run but do not execute them.
+ -v=false
+   Print verbose output.
+
+Jiri profile - Display information about installed profiles
+
+Display information about installed profiles and their configuration.
+
+Usage:
+   jiri profile [flags] <command>
+
+The jiri profile commands are:
+   list        List available or installed profiles
+   env         Display profile environment variables
+   install     Install the given profiles
+   uninstall   Uninstall the given profiles
+   update      Install the latest default version of the given profiles
+   cleanup     Cleanup the locally installed profiles
+   available   List the available profiles
+
+The jiri profile flags are:
+ -color=true
+   Use color to format output.
+ -n=false
+   Show what commands will run but do not execute them.
+ -v=false
+   Print verbose output.
+
+Jiri profile list - List available or installed profiles
+
+List available or installed profiles.
+
+Usage:
+   jiri profile list [flags] [<profiles>]
+
+<profiles> is a list of profiles to list, defaulting to all profiles if none are
+specifically requested.
+
+The jiri profile list flags are:
+ -available=false
+   print the list of available profiles
+ -env=
+   specify an environment variable in the form: <var>=[<val>],...
+ -info=
+   The following fields for use with --profile-info are available:
+   	SchemaVersion - the version of the profiles implementation.
+   	Target.InstallationDir - the installation directory of the requested profile.
+   	Target.CommandLineEnv - the environment variables specified via the command line when installing this profile target.
+   	Target.Env - the environment variables computed by the profile installation process for this target.
+   	Target.Command - a command that can be used to create this profile.
+   	Note: if no --target is specified then the requested field will be displayed for all targets.
+   	Profile.Description - description of the requested profile.
+   	Profile.Root - the root directory of the requested profile.
+   	Profile.Versions - the set of supported versions for this profile.
+   	Profile.DefaultVersion - the default version of the requested profile.
+   	Profile.LatestVersion - the latest version available for the requested profile.
+   	Note: if no profiles are specified then the requested field will be displayed for all profiles.
+ -merge-policies=+CCFLAGS,+CGO_CFLAGS,+CGO_CXXFLAGS,+CGO_LDFLAGS,+CXXFLAGS,GOARCH,GOOS,GOPATH:,^GOROOT*,+LDFLAGS,:PATH,VDLPATH:
+   specify policies for merging environment variables
+ -profiles=base,jiri
+   a comma separated list of profiles to use
+ -profiles-db=$JIRI_ROOT/.jiri_root/profile_db
+   specify the profiles database directory or file.
+ -show-profiles-db=false
+   print out the profiles database file
+ -skip-profiles=false
+   if set, no profiles will be used
+ -target=<runtime.GOARCH>-<runtime.GOOS>
+   specifies a profile target in the following form: <arch>-<os>[@<version>]
+ -v=false
+   print more detailed information
+
+ -color=true
+   Use color to format output.
+ -n=false
+   Show what commands will run but do not execute them.
+
+Jiri profile env - Display profile environment variables
+
+List profile specific and target specific environment variables. If the
+requested environment variable name ends in = then only the value will be
+printed, otherwise both name and value are printed, i.e. GOPATH="foo" vs just
+"foo".
+
+If no environment variable names are requested then all will be printed in
+<name>=<val> format.
+
+Usage:
+   jiri profile env [flags] [<environment variable names>]
+
+[<environment variable names>] is an optional list of environment variables to
+display
+
+The jiri profile env flags are:
+ -env=
+   specify an environment variable in the form: <var>=[<val>],...
+ -merge-policies=+CCFLAGS,+CGO_CFLAGS,+CGO_CXXFLAGS,+CGO_LDFLAGS,+CXXFLAGS,GOARCH,GOOS,GOPATH:,^GOROOT*,+LDFLAGS,:PATH,VDLPATH:
+   specify policies for merging environment variables
+ -profiles=base,jiri
+   a comma separated list of profiles to use
+ -profiles-db=$JIRI_ROOT/.jiri_root/profile_db
+   specify the profiles database directory or file.
+ -skip-profiles=false
+   if set, no profiles will be used
+ -target=<runtime.GOARCH>-<runtime.GOOS>
+   specifies a profile target in the following form: <arch>-<os>[@<version>]
+ -v=false
+   print more detailed information
+
+ -color=true
+   Use color to format output.
+ -n=false
+   Show what commands will run but do not execute them.
+
+Jiri profile install - Install the given profiles
+
+Install the given profiles.
+
+Usage:
+   jiri profile install [flags] <profiles>
+
+<profiles> is a list of profiles to install.
+
+The jiri profile install flags are:
+ -env=
+   specify an environment variable in the form: <var>=[<val>],...
+ -force=false
+   force install the profile even if it is already installed
+ -profiles-db=$JIRI_ROOT/.jiri_root/profile_db
+   specify the profiles database directory or file.
+ -profiles-dir=.jiri_root/profiles
+   the directory, relative to JIRI_ROOT, that profiles are installed in
+ -target=<runtime.GOARCH>-<runtime.GOOS>
+   specifies a profile target in the following form: <arch>-<os>[@<version>]
+
+ -color=true
+   Use color to format output.
+ -n=false
+   Show what commands will run but do not execute them.
+ -v=false
+   Print verbose output.
+
+Jiri profile uninstall - Uninstall the given profiles
+
+Uninstall the given profiles.
+
+Usage:
+   jiri profile uninstall [flags] <profiles>
+
+<profiles> is a list of profiles to uninstall.
+
+The jiri profile uninstall flags are:
+ -all-targets=false
+   apply to all targets for the specified profile(s)
+ -profiles-db=$JIRI_ROOT/.jiri_root/profile_db
+   specify the profiles database directory or file.
+ -profiles-dir=.jiri_root/profiles
+   the directory, relative to JIRI_ROOT, that profiles are installed in
+ -target=<runtime.GOARCH>-<runtime.GOOS>
+   specifies a profile target in the following form: <arch>-<os>[@<version>]
+ -v=false
+   print more detailed information
+
+ -color=true
+   Use color to format output.
+ -n=false
+   Show what commands will run but do not execute them.
+
+Jiri profile update - Install the latest default version of the given profiles
+
+Install the latest default version of the given profiles.
+
+Usage:
+   jiri profile update [flags] <profiles>
+
+<profiles> is a list of profiles to update, if omitted all profiles are updated.
+
+The jiri profile update flags are:
+ -profiles-db=$JIRI_ROOT/.jiri_root/profile_db
+   specify the profiles database directory or file.
+ -profiles-dir=.jiri_root/profiles
+   the directory, relative to JIRI_ROOT, that profiles are installed in
+ -v=false
+   print more detailed information
+
+ -color=true
+   Use color to format output.
+ -n=false
+   Show what commands will run but do not execute them.
+
+Jiri profile cleanup - Cleanup the locally installed profiles
+
+Cleanup the locally installed profiles. This is generally required when
+recovering from earlier bugs or when preparing for a subsequent change to the
+profiles implementation.
+
+Usage:
+   jiri profile cleanup [flags] <profiles>
+
+<profiles> is a list of profiles to cleanup, if omitted all profiles are
+cleaned.
+
+The jiri profile cleanup flags are:
+ -gc=false
+   uninstall profile targets that are older than the current default
+ -profiles-db=$JIRI_ROOT/.jiri_root/profile_db
+   specify the profiles database directory or file.
+ -profiles-dir=.jiri_root/profiles
+   the directory, relative to JIRI_ROOT, that profiles are installed in
+ -rewrite-profiles-db=false
+   rewrite the profiles database to use the latest schema version
+ -rm-all=false
+   remove profiles database and all profile generated output files.
+ -v=false
+   print more detailed information
+
+ -color=true
+   Use color to format output.
+ -n=false
+   Show what commands will run but do not execute them.
+
+Jiri profile available - List the available profiles
+
+List the available profiles.
+
+Usage:
+   jiri profile available [flags]
+
+The jiri profile available flags are:
+ -v=false
+   print more detailed information
+
+ -color=true
+   Use color to format output.
+ -n=false
+   Show what commands will run but do not execute them.
+
+Jiri project - Manage the jiri projects
+
+Manage the jiri projects.
+
+Usage:
+   jiri project [flags] <command>
+
+The jiri project commands are:
+   clean        Restore jiri projects to their pristine state
+   list         List existing jiri projects and branches
+   shell-prompt Print a succinct status of projects suitable for shell prompts
+   poll         Poll existing jiri projects
+
+The jiri project flags are:
+ -color=true
+   Use color to format output.
+ -n=false
+   Show what commands will run but do not execute them.
+ -v=false
+   Print verbose output.
+
+Jiri project clean - Restore jiri projects to their pristine state
+
+Restore jiri projects back to their master branches and get rid of all the local
+branches and changes.
+
+Usage:
+   jiri project clean [flags] <project ...>
+
+<project ...> is a list of projects to clean up.
+
+The jiri project clean flags are:
+ -branches=false
+   Delete all non-master branches.
+
+ -color=true
+   Use color to format output.
+ -n=false
+   Show what commands will run but do not execute them.
+ -v=false
+   Print verbose output.
+
+Jiri project list - List existing jiri projects and branches
+
+Inspect the local filesystem and list the existing projects and branches.
+
+Usage:
+   jiri project list [flags]
+
+The jiri project list flags are:
+ -branches=false
+   Show project branches.
+ -nopristine=false
+   If true, omit pristine projects, i.e. projects with a clean master branch and
+   no other branches.
+
+ -color=true
+   Use color to format output.
+ -n=false
+   Show what commands will run but do not execute them.
+ -v=false
+   Print verbose output.
+
+Jiri project shell-prompt - Print a succinct status of projects suitable for shell prompts
+
+Reports current branches of jiri projects (repositories) as well as an
+indication of each project's status:
+  *  indicates that a repository contains uncommitted changes
+  %  indicates that a repository contains untracked files
+
+Usage:
+   jiri project shell-prompt [flags]
+
+The jiri project shell-prompt flags are:
+ -check-dirty=true
+   If false, don't check for uncommitted changes or untracked files. Setting
+   this option to false is dangerous: dirty master branches will not appear in
+   the output.
+ -show-name=false
+   Show the name of the current repo.
+
+ -color=true
+   Use color to format output.
+ -n=false
+   Show what commands will run but do not execute them.
+ -v=false
+   Print verbose output.
+
+Jiri project poll - Poll existing jiri projects
+
+Poll jiri projects that can affect the outcome of the given tests and report
+whether any new changes in these projects exist. If no tests are specified, all
+projects are polled by default.
+
+Usage:
+   jiri project poll [flags] <test ...>
+
+<test ...> is a list of tests that determine what projects to poll.
+
+The jiri project poll flags are:
+ -manifest=
+   Name of the project manifest.
+
+ -color=true
+   Use color to format output.
+ -n=false
+   Show what commands will run but do not execute them.
+ -v=false
+   Print verbose output.
+
+Jiri rebuild - Rebuild all jiri tools
+
+Rebuilds all jiri tools and installs the resulting binaries into
+$JIRI_ROOT/.jiri_root/bin. This is similar to "jiri update", but does not update
+any projects before building the tools. The set of tools to rebuild is described
+in the manifest.
+
+Run "jiri help manifest" for details on manifests.
+
+Usage:
+   jiri rebuild [flags]
+
+The jiri rebuild flags are:
+ -color=true
+   Use color to format output.
+ -n=false
+   Show what commands will run but do not execute them.
+ -v=false
+   Print verbose output.
+
+Jiri snapshot - Manage project snapshots
+
+The "jiri snapshot" command can be used to manage project snapshots. In
+particular, it can be used to create new snapshots and to list existing
+snapshots.
+
+Usage:
+   jiri snapshot [flags] <command>
+
+The jiri snapshot commands are:
+   checkout    Checkout a project snapshot
+   create      Create a new project snapshot
+   list        List existing project snapshots
+
+The jiri snapshot flags are:
+ -dir=
+   Directory where snapshot are stored.  Defaults to $JIRI_ROOT/.snapshot.
+
+ -color=true
+   Use color to format output.
+ -n=false
+   Show what commands will run but do not execute them.
+ -v=false
+   Print verbose output.
+
+Jiri snapshot checkout - Checkout a project snapshot
+
+The "jiri snapshot checkout <snapshot>" command restores local project state to
+the state in the given snapshot manifest.
+
+Usage:
+   jiri snapshot checkout [flags] <snapshot>
+
+<snapshot> is the snapshot manifest file.
+
+The jiri snapshot checkout flags are:
+ -gc=false
+   Garbage collect obsolete repositories.
+
+ -color=true
+   Use color to format output.
+ -dir=
+   Directory where snapshot are stored.  Defaults to $JIRI_ROOT/.snapshot.
+ -n=false
+   Show what commands will run but do not execute them.
+ -v=false
+   Print verbose output.
+
+Jiri snapshot create - Create a new project snapshot
+
+The "jiri snapshot create <label>" command captures the current project state in
+a manifest.  If the -push-remote flag is provided, the snapshot is committed and
+pushed upstream.
+
+Internally, snapshots are organized as follows:
+
+ <snapshot-dir>/
+   labels/
+     <label1>/
+       <label1-snapshot1>
+       <label1-snapshot2>
+       ...
+     <label2>/
+       <label2-snapshot1>
+       <label2-snapshot2>
+       ...
+     <label3>/
+     ...
+   <label1> # a symlink to the latest <label1-snapshot*>
+   <label2> # a symlink to the latest <label2-snapshot*>
+   ...
+
+NOTE: Unlike the jiri tool commands, the above internal organization is not an
+API. It is an implementation and can change without notice.
+
+Usage:
+   jiri snapshot create [flags] <label>
+
+<label> is the snapshot label.
+
+The jiri snapshot create flags are:
+ -push-remote=false
+   Commit and push snapshot upstream.
+ -time-format=2006-01-02T15:04:05Z07:00
+   Time format for snapshot file name.
+
+ -color=true
+   Use color to format output.
+ -dir=
+   Directory where snapshot are stored.  Defaults to $JIRI_ROOT/.snapshot.
+ -n=false
+   Show what commands will run but do not execute them.
+ -v=false
+   Print verbose output.
+
+Jiri snapshot list - List existing project snapshots
+
+The "snapshot list" command lists existing snapshots of the labels specified as
+command-line arguments. If no arguments are provided, the command lists
+snapshots for all known labels.
+
+Usage:
+   jiri snapshot list [flags] <label ...>
+
+<label ...> is a list of snapshot labels.
+
+The jiri snapshot list flags are:
+ -color=true
+   Use color to format output.
+ -dir=
+   Directory where snapshot are stored.  Defaults to $JIRI_ROOT/.snapshot.
+ -n=false
+   Show what commands will run but do not execute them.
+ -v=false
+   Print verbose output.
+
+Jiri update - Update all jiri tools and projects
+
+Updates all projects, builds the latest version of all tools, and installs the
+resulting binaries into $JIRI_ROOT/.jiri_root/bin. The sequence in which the
+individual updates happen guarantees that we end up with a consistent set of
+tools and source code. The set of projects and tools to update is described in
+the manifest.
+
+Run "jiri help manifest" for details on manifests.
+
+Usage:
+   jiri update [flags]
+
+The jiri update flags are:
+ -attempts=1
+   Number of attempts before failing.
+ -gc=false
+   Garbage collect obsolete repositories.
+ -manifest=
+   Name of the project manifest.
+
+ -color=true
+   Use color to format output.
+ -n=false
+   Show what commands will run but do not execute them.
+ -v=false
+   Print verbose output.
+
+Jiri upgrade - Upgrade jiri to new-style manifests
+
+Upgrades jiri to use new-style manifests.
+
+The old (deprecated) behavior only allowed a single manifest repository, located
+in $JIRI_ROOT/.manifest.  The initial manifest file is located as follows:
+  1) Use -manifest flag, if non-empty.  If it's empty...
+  2) Use $JIRI_ROOT/.local_manifest file.  If it doesn't exist...
+  3) Use $JIRI_ROOT/.manifest/v2/default.
+
+The new behavior allows multiple manifest repositories, by allowing imports to
+specify project attributes describing the remote repository.  The -manifest flag
+is no longer allowed to be set; the initial manifest file is always located in
+$JIRI_ROOT/.jiri_manifest.  The .local_manifest file is ignored.
+
+During the transition phase, both old and new behaviors are supported.  The jiri
+tool uses the existence of the $JIRI_ROOT/.jiri_manifest file as the signal; if
+it exists we run the new behavior, otherwise we run the old behavior.
+
+The new behavior includes a "jiri import" command, which writes or updates the
+.jiri_manifest file.  The new bootstrap procedure runs "jiri import", and it is
+intended as a regular command to add imports to your jiri environment.
+
+This upgrade command eases the transition by writing an initial .jiri_manifest
+file for you.  If you have an existing .local_manifest file, its contents will
+be incorporated into the new .jiri_manifest file, and it will be renamed to
+.local_manifest.BACKUP.  The -revert flag deletes the .jiri_manifest file, and
+restores the .local_manifest file.
+
+Usage:
+   jiri upgrade [flags] <kind>
+
+<kind> specifies the kind of upgrade, one of "v23" or "fuchsia".
+
+The jiri upgrade flags are:
+ -revert=false
+   Revert the upgrade by deleting the $JIRI_ROOT/.jiri_manifest file.
+
+ -color=true
+   Use color to format output.
+ -n=false
+   Show what commands will run but do not execute them.
+ -v=false
+   Print verbose output.
+
+Jiri which - Show path to the jiri tool
+
+Which behaves similarly to the unix commandline tool.  It is useful in
+determining whether the jiri binary is being run directly, or run via the jiri
+shim script.
+
+If the binary is being run directly, the output looks like this:
+
+  # binary
+  /path/to/binary/jiri
+
+If the script is being run, the output looks like this:
+
+  # script
+  /path/to/script/jiri
+
+Usage:
+   jiri which [flags]
+
+The jiri which flags are:
+ -color=true
+   Use color to format output.
+ -n=false
+   Show what commands will run but do not execute them.
+ -v=false
+   Print verbose output.
+
+Jiri help - Display help for commands or topics
+
+Help with no args displays the usage of the parent command.
+
+Help with args displays the usage of the specified sub-command or help topic.
+
+"help ..." recursively displays help for all commands and topics.
+
+Usage:
+   jiri help [flags] [command/topic ...]
+
+[command/topic ...] optionally identifies a specific sub-command or help topic.
+
+The jiri help flags are:
+ -style=compact
+   The formatting style for help output:
+      compact   - Good for compact cmdline output.
+      full      - Good for cmdline output, shows all global flags.
+      godoc     - Good for godoc processing.
+      shortonly - Only output short description.
+   Override the default by setting the CMDLINE_STYLE environment variable.
+ -width=<terminal width>
+   Format output to this target width in runes, or unlimited if width < 0.
+   Defaults to the terminal width if available.  Override the default by setting
+   the CMDLINE_WIDTH environment variable.
+
+Jiri filesystem - Description of jiri file system layout
+
+All data managed by the jiri tool is located in the file system under a root
+directory, colloquially called the jiri root directory.  The file system layout
+looks like this:
+
+ [root]                              # root directory (name picked by user)
+ [root]/.jiri_root                   # root metadata directory
+ [root]/.jiri_root/bin               # contains tool binaries (jiri, etc.)
+ [root]/.jiri_root/update_history    # contains history of update snapshots
+ [root]/.manifest                    # contains jiri manifests
+ [root]/[project1]                   # project directory (name picked by user)
+ [root]/[project1]/.jiri             # project metadata directory
+ [root]/[project1]/.jiri/metadata.v2 # project metadata file
+ [root]/[project1]/.jiri/<<cls>>     # project per-cl metadata directories
+ [root]/[project1]/<<files>>         # project files
+ [root]/[project2]...
+
+The [root] and [projectN] directory names are picked by the user.  The <<cls>>
+are named via jiri cl new, and the <<files>> are named as the user adds files
+and directories to their project.  All other names above have special meaning to
+the jiri tool, and cannot be changed; you must ensure your path names don't
+collide with these special names.
+
+There are two ways to run the jiri tool:
+
+1) Shim script (recommended approach).  This is a shell script that looks for
+the [root] directory.  If the JIRI_ROOT environment variable is set, that is
+assumed to be the [root] directory.  Otherwise the script looks for the
+.jiri_root directory, starting in the current working directory and walking up
+the directory chain.  The search is terminated successfully when the .jiri_root
+directory is found; it fails after it reaches the root of the file system.  Thus
+the shim must be invoked from the [root] directory or one of its subdirectories.
+
+Once the [root] is found, the JIRI_ROOT environment variable is set to its
+location, and [root]/.jiri_root/bin/jiri is invoked.  That file contains the
+actual jiri binary.
+
+The point of the shim script is to make it easy to use the jiri tool with
+multiple [root] directories on your file system.  Keep in mind that when "jiri
+update" is run, the jiri tool itself is automatically updated along with all
+projects.  By using the shim script, you only need to remember to invoke the
+jiri tool from within the appropriate [root] directory, and the projects and
+tools under that [root] directory will be updated.
+
+The shim script is located at [root]/release/go/src/v.io/jiri/scripts/jiri
+
+2) Direct binary.  This is the jiri binary, containing all of the actual jiri
+tool logic.  The binary requires the JIRI_ROOT environment variable to point to
+the [root] directory.
+
+Note that if you have multiple [root] directories on your file system, you must
+remember to run the jiri binary corresponding to the setting of your JIRI_ROOT
+environment variable.  Things may fail if you mix things up, since the jiri
+binary is updated with each call to "jiri update", and you may encounter version
+mismatches between the jiri binary and the various metadata files or other
+logic.  This is the reason the shim script is recommended over running the
+binary directly.
+
+The binary is located at [root]/.jiri_root/bin/jiri
+
+Jiri manifest - Description of manifest files
+
+Jiri manifests are revisioned and stored in a "manifest" repository, that is
+available locally in $JIRI_ROOT/.manifest. The manifest uses the following XML
+schema:
+
+ <manifest>
+   <imports>
+     <import name="default"/>
+     ...
+   </imports>
+   <projects>
+     <project name="release.go.jiri"
+              path="release/go/src/v.io/jiri"
+              protocol="git"
+              name="https://vanadium.googlesource.com/release.go.jiri"
+              revision="HEAD"/>
+     ...
+   </projects>
+   <tools>
+     <tool name="jiri" package="v.io/jiri"/>
+     ...
+   </tools>
+ </manifest>
+
+The <import> element can be used to share settings across multiple manifests.
+Import names are interpreted relative to the $JIRI_ROOT/.manifest/v2 directory.
+Import cycles are not allowed and if a project or a tool is specified multiple
+times, the last specification takes effect. In particular, the elements <project
+name="foo" exclude="true"/> and <tool name="bar" exclude="true"/> can be used to
+exclude previously included projects and tools.
+
+The tool identifies which manifest to use using the following algorithm. If the
+$JIRI_ROOT/.local_manifest file exists, then it is used. Otherwise, the
+$JIRI_ROOT/.manifest/v2/<manifest>.xml file is used, where <manifest> is the
+value of the -manifest command-line flag, which defaults to "default".
+*/
+package main
diff --git a/cmd/jiri/import.go b/cmd/jiri/import.go
new file mode 100644
index 0000000..7636f13
--- /dev/null
+++ b/cmd/jiri/import.go
@@ -0,0 +1,101 @@
+// Copyright 2015 The Vanadium 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 (
+	"os"
+
+	"v.io/jiri/jiri"
+	"v.io/jiri/project"
+	"v.io/jiri/runutil"
+	"v.io/x/lib/cmdline"
+)
+
+var (
+	// Flags for configuring project attributes for remote imports.
+	flagImportName, flagImportProtocol, flagImportRemoteBranch, flagImportRoot string
+	// Flags for controlling the behavior of the command.
+	flagImportOverwrite bool
+	flagImportOut       string
+)
+
+func init() {
+	cmdImport.Flags.StringVar(&flagImportName, "name", "", `The name of the remote manifest project, used to disambiguate manifest projects with the same remote.  Typically empty.`)
+	cmdImport.Flags.StringVar(&flagImportProtocol, "protocol", "git", `The version control protocol used by the remote manifest project.`)
+	cmdImport.Flags.StringVar(&flagImportRemoteBranch, "remote-branch", "master", `The branch of the remote manifest project to track, without the leading "origin/".`)
+	cmdImport.Flags.StringVar(&flagImportRoot, "root", "", `Root to store the manifest project locally.`)
+
+	cmdImport.Flags.BoolVar(&flagImportOverwrite, "overwrite", false, `Write a new .jiri_manifest file with the given specification.  If it already exists, the existing content will be ignored and the file will be overwritten.`)
+	cmdImport.Flags.StringVar(&flagImportOut, "out", "", `The output file.  Uses $JIRI_ROOT/.jiri_manifest if unspecified.  Uses stdout if set to "-".`)
+}
+
+var cmdImport = &cmdline.Command{
+	Runner: jiri.RunnerFunc(runImport),
+	Name:   "import",
+	Short:  "Adds imports to .jiri_manifest file",
+	Long: `
+Command "import" adds imports to the $JIRI_ROOT/.jiri_manifest file, which
+specifies manifest information for the jiri tool.  The file is created if it
+doesn't already exist, otherwise additional imports are added to the existing
+file.
+
+An <import> element is added to the manifest representing a remote manifest
+import.  The manifest file path is relative to the root directory of the remote
+import repository.
+
+Example:
+  $ jiri import myfile https://foo.com/bar.git
+
+Run "jiri help manifest" for details on manifests.
+`,
+	ArgsName: "<manifest> <remote>",
+	ArgsLong: `
+<manifest> specifies the manifest file to use.
+
+<remote> specifies the remote manifest repository.
+`,
+}
+
+func runImport(jirix *jiri.X, args []string) error {
+	if len(args) != 2 {
+		return jirix.UsageErrorf("wrong number of arguments")
+	}
+	// Initialize manifest.
+	var manifest *project.Manifest
+	if !flagImportOverwrite {
+		m, err := project.ManifestFromFile(jirix, jirix.JiriManifestFile())
+		if err != nil && !runutil.IsNotExist(err) {
+			return err
+		}
+		manifest = m
+	}
+	if manifest == nil {
+		manifest = &project.Manifest{}
+	}
+	// There's not much error checking when writing the .jiri_manifest file;
+	// errors will be reported when "jiri update" is run.
+	manifest.Imports = append(manifest.Imports, project.Import{
+		Manifest:     args[0],
+		Name:         flagImportName,
+		Protocol:     flagImportProtocol,
+		Remote:       args[1],
+		RemoteBranch: flagImportRemoteBranch,
+		Root:         flagImportRoot,
+	})
+	// Write output to stdout or file.
+	outFile := flagImportOut
+	if outFile == "" {
+		outFile = jirix.JiriManifestFile()
+	}
+	if outFile == "-" {
+		bytes, err := manifest.ToBytes()
+		if err != nil {
+			return err
+		}
+		_, err = os.Stdout.Write(bytes)
+		return err
+	}
+	return manifest.ToFile(jirix, outFile)
+}
diff --git a/cmd/jiri/import_test.go b/cmd/jiri/import_test.go
new file mode 100644
index 0000000..7b13b97
--- /dev/null
+++ b/cmd/jiri/import_test.go
@@ -0,0 +1,199 @@
+// Copyright 2015 The Vanadium 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"
+	"io/ioutil"
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+
+	"v.io/jiri/jiri"
+	"v.io/x/lib/gosh"
+)
+
+type importTestCase struct {
+	Args           []string
+	Filename       string
+	Exist, Want    string
+	Stdout, Stderr string
+}
+
+func TestImport(t *testing.T) {
+	tests := []importTestCase{
+		{
+			Stderr: `wrong number of arguments`,
+		},
+		{
+			Args:   []string{"a"},
+			Stderr: `wrong number of arguments`,
+		},
+		{
+			Args:   []string{"a", "b", "c"},
+			Stderr: `wrong number of arguments`,
+		},
+		// Remote imports, default append behavior
+		{
+			Args: []string{"-name=name", "-remote-branch=remotebranch", "-root=root", "foo", "https://github.com/new.git"},
+			Want: `<manifest>
+  <imports>
+    <import manifest="foo" name="name" remote="https://github.com/new.git" remotebranch="remotebranch" root="root"/>
+  </imports>
+</manifest>
+`,
+		},
+		{
+			Args: []string{"foo", "https://github.com/new.git"},
+			Want: `<manifest>
+  <imports>
+    <import manifest="foo" remote="https://github.com/new.git"/>
+  </imports>
+</manifest>
+`,
+		},
+		{
+			Args:     []string{"-out=file", "foo", "https://github.com/new.git"},
+			Filename: `file`,
+			Want: `<manifest>
+  <imports>
+    <import manifest="foo" remote="https://github.com/new.git"/>
+  </imports>
+</manifest>
+`,
+		},
+		{
+			Args: []string{"-out=-", "foo", "https://github.com/new.git"},
+			Stdout: `<manifest>
+  <imports>
+    <import manifest="foo" remote="https://github.com/new.git"/>
+  </imports>
+</manifest>
+`,
+		},
+		{
+			Args: []string{"foo", "https://github.com/new.git"},
+			Exist: `<manifest>
+  <imports>
+    <import manifest="bar" remote="https://github.com/orig.git"/>
+  </imports>
+</manifest>
+`,
+			Want: `<manifest>
+  <imports>
+    <import manifest="bar" remote="https://github.com/orig.git"/>
+    <import manifest="foo" remote="https://github.com/new.git"/>
+  </imports>
+</manifest>
+`,
+		},
+		// Remote imports, explicit overwrite behavior
+		{
+			Args: []string{"-overwrite", "foo", "https://github.com/new.git"},
+			Want: `<manifest>
+  <imports>
+    <import manifest="foo" remote="https://github.com/new.git"/>
+  </imports>
+</manifest>
+`,
+		},
+		{
+			Args:     []string{"-overwrite", "-out=file", "foo", "https://github.com/new.git"},
+			Filename: `file`,
+			Want: `<manifest>
+  <imports>
+    <import manifest="foo" remote="https://github.com/new.git"/>
+  </imports>
+</manifest>
+`,
+		},
+		{
+			Args: []string{"-overwrite", "-out=-", "foo", "https://github.com/new.git"},
+			Stdout: `<manifest>
+  <imports>
+    <import manifest="foo" remote="https://github.com/new.git"/>
+  </imports>
+</manifest>
+`,
+		},
+		{
+			Args: []string{"-overwrite", "foo", "https://github.com/new.git"},
+			Exist: `<manifest>
+  <imports>
+    <import manifest="bar" remote="https://github.com/orig.git"/>
+  </imports>
+</manifest>
+`,
+			Want: `<manifest>
+  <imports>
+    <import manifest="foo" remote="https://github.com/new.git"/>
+  </imports>
+</manifest>
+`,
+		},
+	}
+	opts := gosh.Opts{Fatalf: t.Fatalf, Logf: t.Logf}
+	sh := gosh.NewShell(opts)
+	defer sh.Cleanup()
+	jiriTool := sh.BuildGoPkg("v.io/jiri")
+	for _, test := range tests {
+		if err := testImport(opts, jiriTool, test); err != nil {
+			t.Errorf("%v: %v", test.Args, err)
+		}
+	}
+}
+
+func testImport(opts gosh.Opts, jiriTool string, test importTestCase) error {
+	sh := gosh.NewShell(opts)
+	defer sh.Cleanup()
+	tmpDir := sh.MakeTempDir()
+	jiriRoot := filepath.Join(tmpDir, "root")
+	if err := os.Mkdir(jiriRoot, 0755); err != nil {
+		return err
+	}
+	sh.Pushd(jiriRoot)
+	filename := test.Filename
+	if filename == "" {
+		filename = ".jiri_manifest"
+	}
+	// Set up manfile for the local file import tests.  It should exist in both
+	// the tmpDir (for ../manfile tests) and jiriRoot.
+	for _, dir := range []string{tmpDir, jiriRoot} {
+		if err := ioutil.WriteFile(filepath.Join(dir, "manfile"), nil, 0644); err != nil {
+			return err
+		}
+	}
+	// Set up an existing file if it was specified.
+	if test.Exist != "" {
+		if err := ioutil.WriteFile(filename, []byte(test.Exist), 0644); err != nil {
+			return err
+		}
+	}
+	// Run import and check the error.
+	sh.Vars[jiri.RootEnv] = jiriRoot
+	cmd := sh.Cmd(jiriTool, append([]string{"import"}, test.Args...)...)
+	if test.Stderr != "" {
+		cmd.ExitErrorIsOk = true
+	}
+	stdout, stderr := cmd.StdoutStderr()
+	if got, want := stdout, test.Stdout; !strings.Contains(got, want) || (got != "" && want == "") {
+		return fmt.Errorf("stdout got %q, want substr %q", got, want)
+	}
+	if got, want := stderr, test.Stderr; !strings.Contains(got, want) || (got != "" && want == "") {
+		return fmt.Errorf("stderr got %q, want substr %q", got, want)
+	}
+	// Make sure the right file is generated.
+	if test.Want != "" {
+		data, err := ioutil.ReadFile(filename)
+		if err != nil {
+			return err
+		}
+		if got, want := string(data), test.Want; got != want {
+			return fmt.Errorf("GOT\n%s\nWANT\n%s", got, want)
+		}
+	}
+	return nil
+}
diff --git a/cmd/jiri/profile.go b/cmd/jiri/profile.go
new file mode 100644
index 0000000..a472a09
--- /dev/null
+++ b/cmd/jiri/profile.go
@@ -0,0 +1,22 @@
+// Copyright 2015 The Vanadium 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 (
+	"v.io/jiri/jiri"
+	"v.io/jiri/profiles/profilescmdline"
+	"v.io/x/lib/cmdline"
+)
+
+var cmdProfile = &cmdline.Command{
+	Name:  "profile",
+	Short: "Display information about installed profiles",
+	Long:  "Display information about installed profiles and their configuration.",
+}
+
+func init() {
+	profilescmdline.RegisterReaderCommands(cmdProfile, jiri.ProfilesDBDir)
+	profilescmdline.RegisterManagementCommands(cmdProfile, "", jiri.ProfilesDBDir, jiri.ProfilesRootDir)
+}
diff --git a/cmd/jiri/project.go b/cmd/jiri/project.go
new file mode 100644
index 0000000..ea94798
--- /dev/null
+++ b/cmd/jiri/project.go
@@ -0,0 +1,252 @@
+// Copyright 2015 The Vanadium 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 (
+	"encoding/json"
+	"fmt"
+	"path/filepath"
+	"sort"
+	"strings"
+
+	"v.io/jiri/jiri"
+	"v.io/jiri/project"
+	"v.io/jiri/tool"
+	"v.io/jiri/util"
+	"v.io/x/lib/cmdline"
+	"v.io/x/lib/set"
+)
+
+var (
+	branchesFlag        bool
+	cleanupBranchesFlag bool
+	noPristineFlag      bool
+	checkDirtyFlag      bool
+	showNameFlag        bool
+)
+
+func init() {
+	cmdProjectClean.Flags.BoolVar(&cleanupBranchesFlag, "branches", false, "Delete all non-master branches.")
+	cmdProjectList.Flags.BoolVar(&branchesFlag, "branches", false, "Show project branches.")
+	cmdProjectList.Flags.BoolVar(&noPristineFlag, "nopristine", false, "If true, omit pristine projects, i.e. projects with a clean master branch and no other branches.")
+	cmdProjectShellPrompt.Flags.BoolVar(&checkDirtyFlag, "check-dirty", true, "If false, don't check for uncommitted changes or untracked files. Setting this option to false is dangerous: dirty master branches will not appear in the output.")
+	cmdProjectShellPrompt.Flags.BoolVar(&showNameFlag, "show-name", false, "Show the name of the current repo.")
+
+	tool.InitializeProjectFlags(&cmdProjectPoll.Flags)
+
+}
+
+// cmdProject represents the "jiri project" command.
+var cmdProject = &cmdline.Command{
+	Name:     "project",
+	Short:    "Manage the jiri projects",
+	Long:     "Manage the jiri projects.",
+	Children: []*cmdline.Command{cmdProjectClean, cmdProjectList, cmdProjectShellPrompt, cmdProjectPoll},
+}
+
+// cmdProjectClean represents the "jiri project clean" command.
+var cmdProjectClean = &cmdline.Command{
+	Runner:   jiri.RunnerFunc(runProjectClean),
+	Name:     "clean",
+	Short:    "Restore jiri projects to their pristine state",
+	Long:     "Restore jiri projects back to their master branches and get rid of all the local branches and changes.",
+	ArgsName: "<project ...>",
+	ArgsLong: "<project ...> is a list of projects to clean up.",
+}
+
+func runProjectClean(jirix *jiri.X, args []string) (e error) {
+	localProjects, err := project.LocalProjects(jirix, project.FullScan)
+	if err != nil {
+		return err
+	}
+	var projects project.Projects
+	if len(args) > 0 {
+		for _, arg := range args {
+			p, err := localProjects.FindUnique(arg)
+			if err != nil {
+				fmt.Fprintf(jirix.Stderr(), "Error finding local project %q: %v.\n", p.Name, err)
+			} else {
+				projects[p.Key()] = p
+			}
+		}
+	} else {
+		projects = localProjects
+	}
+	if err := project.CleanupProjects(jirix, projects, cleanupBranchesFlag); err != nil {
+		return err
+	}
+	return nil
+}
+
+// cmdProjectList represents the "jiri project list" command.
+var cmdProjectList = &cmdline.Command{
+	Runner: jiri.RunnerFunc(runProjectList),
+	Name:   "list",
+	Short:  "List existing jiri projects and branches",
+	Long:   "Inspect the local filesystem and list the existing projects and branches.",
+}
+
+// runProjectList generates a listing of local projects.
+func runProjectList(jirix *jiri.X, _ []string) error {
+	states, err := project.GetProjectStates(jirix, noPristineFlag)
+	if err != nil {
+		return err
+	}
+	var keys project.ProjectKeys
+	for key := range states {
+		keys = append(keys, key)
+	}
+	sort.Sort(keys)
+
+	for _, key := range keys {
+		state := states[key]
+		if noPristineFlag {
+			pristine := len(state.Branches) == 1 && state.CurrentBranch == "master" && !state.HasUncommitted && !state.HasUntracked
+			if pristine {
+				continue
+			}
+		}
+		fmt.Fprintf(jirix.Stdout(), "name=%q remote=%q path=%q\n", state.Project.Name, state.Project.Remote, state.Project.Path)
+		if branchesFlag {
+			for _, branch := range state.Branches {
+				s := "  "
+				if branch.Name == state.CurrentBranch {
+					s += "* "
+				}
+				s += branch.Name
+				if branch.HasGerritMessage {
+					s += " (exported to gerrit)"
+				}
+				fmt.Fprintf(jirix.Stdout(), "%v\n", s)
+			}
+		}
+	}
+	return nil
+}
+
+// cmdProjectShellPrompt represents the "jiri project shell-prompt" command.
+var cmdProjectShellPrompt = &cmdline.Command{
+	Runner: jiri.RunnerFunc(runProjectShellPrompt),
+	Name:   "shell-prompt",
+	Short:  "Print a succinct status of projects suitable for shell prompts",
+	Long: `
+Reports current branches of jiri projects (repositories) as well as an
+indication of each project's status:
+  *  indicates that a repository contains uncommitted changes
+  %  indicates that a repository contains untracked files
+`,
+}
+
+func runProjectShellPrompt(jirix *jiri.X, args []string) error {
+	states, err := project.GetProjectStates(jirix, checkDirtyFlag)
+	if err != nil {
+		return err
+	}
+	var keys project.ProjectKeys
+	for key := range states {
+		keys = append(keys, key)
+	}
+	sort.Sort(keys)
+
+	// Get the key of the current project.
+	currentProjectKey, err := project.CurrentProjectKey(jirix)
+	if err != nil {
+		return err
+	}
+	var statuses []string
+	for _, key := range keys {
+		state := states[key]
+		status := ""
+		if checkDirtyFlag {
+			if state.HasUncommitted {
+				status += "*"
+			}
+			if state.HasUntracked {
+				status += "%"
+			}
+		}
+		short := state.CurrentBranch + status
+		long := filepath.Base(states[key].Project.Name) + ":" + short
+		if key == currentProjectKey {
+			if showNameFlag {
+				statuses = append([]string{long}, statuses...)
+			} else {
+				statuses = append([]string{short}, statuses...)
+			}
+		} else {
+			pristine := state.CurrentBranch == "master"
+			if checkDirtyFlag {
+				pristine = pristine && !state.HasUncommitted && !state.HasUntracked
+			}
+			if !pristine {
+				statuses = append(statuses, long)
+			}
+		}
+	}
+	fmt.Println(strings.Join(statuses, ","))
+	return nil
+}
+
+// cmdProjectPoll represents the "jiri project poll" command.
+var cmdProjectPoll = &cmdline.Command{
+	Runner: jiri.RunnerFunc(runProjectPoll),
+	Name:   "poll",
+	Short:  "Poll existing jiri projects",
+	Long: `
+Poll jiri projects that can affect the outcome of the given tests
+and report whether any new changes in these projects exist. If no
+tests are specified, all projects are polled by default.
+`,
+	ArgsName: "<test ...>",
+	ArgsLong: "<test ...> is a list of tests that determine what projects to poll.",
+}
+
+// runProjectPoll generates a description of changes that exist
+// remotely but do not exist locally.
+func runProjectPoll(jirix *jiri.X, args []string) error {
+	projectSet := map[string]struct{}{}
+	if len(args) > 0 {
+		config, err := util.LoadConfig(jirix)
+		if err != nil {
+			return err
+		}
+		// Compute a map from tests to projects that can change the
+		// outcome of the test.
+		testProjects := map[string][]string{}
+		for _, project := range config.Projects() {
+			for _, test := range config.ProjectTests([]string{project}) {
+				testProjects[test] = append(testProjects[test], project)
+			}
+		}
+		for _, arg := range args {
+			projects, ok := testProjects[arg]
+			if !ok {
+				return fmt.Errorf("failed to find any projects for test %q", arg)
+			}
+			set.String.Union(projectSet, set.String.FromSlice(projects))
+		}
+	}
+	update, err := project.PollProjects(jirix, projectSet)
+	if err != nil {
+		return err
+	}
+
+	// Remove projects with empty changes.
+	for project := range update {
+		if changes := update[project]; len(changes) == 0 {
+			delete(update, project)
+		}
+	}
+
+	// Print update if it is not empty.
+	if len(update) > 0 {
+		bytes, err := json.MarshalIndent(update, "", "  ")
+		if err != nil {
+			return fmt.Errorf("MarshalIndent() failed: %v", err)
+		}
+		fmt.Fprintf(jirix.Stdout(), "%s\n", bytes)
+	}
+	return nil
+}
diff --git a/cmd/jiri/rebuild.go b/cmd/jiri/rebuild.go
new file mode 100644
index 0000000..dbf30bc
--- /dev/null
+++ b/cmd/jiri/rebuild.go
@@ -0,0 +1,56 @@
+// Copyright 2015 The Vanadium 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"
+
+	"v.io/jiri/collect"
+	"v.io/jiri/jiri"
+	"v.io/jiri/project"
+	"v.io/x/lib/cmdline"
+)
+
+// cmdRebuild represents the "jiri rebuild" command.
+var cmdRebuild = &cmdline.Command{
+	Runner: jiri.RunnerFunc(runRebuild),
+	Name:   "rebuild",
+	Short:  "Rebuild all jiri tools",
+	Long: `
+Rebuilds all jiri tools and installs the resulting binaries into
+$JIRI_ROOT/.jiri_root/bin. This is similar to "jiri update", but does not update
+any projects before building the tools. The set of tools to rebuild is described
+in the manifest.
+
+Run "jiri help manifest" for details on manifests.
+`,
+}
+
+func runRebuild(jirix *jiri.X, args []string) (e error) {
+	projects, tools, err := project.LoadManifest(jirix)
+	if err != nil {
+		return err
+	}
+
+	// Create a temporary directory in which tools will be built.
+	tmpDir, err := jirix.NewSeq().TempDir("", "tmp-jiri-rebuild")
+	if err != nil {
+		return fmt.Errorf("TempDir() failed: %v", err)
+	}
+
+	// Make sure we cleanup the temp directory.
+	defer collect.Error(func() error { return jirix.NewSeq().RemoveAll(tmpDir).Done() }, &e)
+
+	// Paranoid sanity checking.
+	if _, ok := tools[project.JiriName]; !ok {
+		return fmt.Errorf("tool %q not found", project.JiriName)
+	}
+
+	// Build and install tools.
+	if err := project.BuildTools(jirix, projects, tools, tmpDir); err != nil {
+		return err
+	}
+	return project.InstallTools(jirix, tmpDir)
+}
diff --git a/cmd/jiri/scripts/bootstrap_jiri b/cmd/jiri/scripts/bootstrap_jiri
new file mode 100755
index 0000000..8f00a2f
--- /dev/null
+++ b/cmd/jiri/scripts/bootstrap_jiri
@@ -0,0 +1,76 @@
+#!/bin/bash
+# Copyright 2015 The Vanadium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file.
+
+# bootstrap_jiri initializes a root directory for jiri.  The following
+# directories and files will be created:
+#   <root_dir>                         - root directory (picked by user)
+#   <root_dir>/.jiri_root              - root metadata directory
+#   <root_dir>/.jiri_root/bin/jiri     - jiri binary
+#   <root_dir>/.jiri_root/scripts/jiri - jiri script
+#
+# The jiri sources are downloaded and built into a temp directory, which is
+# always deleted when this script finishes.  The <root_dir> is deleted on any
+# failure.
+
+set -euf -o pipefail
+
+# fatal prints an error message, followed by the usage string, and then exits.
+fatal() {
+  usage='
+
+Usage:
+   bootstrap_jiri <root_dir>
+
+A typical bootstrap workflow looks like this:
+
+$ curl -s https://raw.githubusercontent.com/vanadium/go.jiri/master/scripts/bootstrap_jiri | bash -s myroot
+$ export PATH=myroot/.jiri_root/scripts:$PATH
+$ cd myroot
+$ jiri import public https://vanadium.googlesource.com/manifest
+$ jiri update'
+  echo "ERROR: $@${usage}" 1>&2
+  exit 1
+}
+
+# toabs converts the possibly relative argument into an absolute path.  Run in a
+# subshell to avoid changing the caller's working directory.
+toabs() (
+  cd $(dirname $1)
+  echo ${PWD}/$(basename $1)
+)
+
+# Check the <root_dir> argument is supplied and doesn't already exist.
+if [[ $# -ne 1 ]]; then
+  fatal "need <root_dir> argument"
+fi
+mkdir -p $(dirname $1)
+root_dir=$(toabs $1)
+if [[ -e ${root_dir} ]]; then
+  fatal "${root_dir} already exists"
+fi
+# Check that go is on the PATH.
+if ! go version >& /dev/null ; then
+  fatal 'ERROR: "go" tool not found, see https://golang.org/doc/install'
+fi
+
+trap "rm -rf ${root_dir}" INT TERM EXIT
+
+# Make the output directories.
+tmp_dir="${root_dir}/.jiri_root/tmp"
+bin_dir="${root_dir}/.jiri_root/bin"
+scripts_dir="${root_dir}/.jiri_root/scripts"
+mkdir -p "${tmp_dir}" "${bin_dir}" "${scripts_dir}"
+
+# Go get the jiri source files, build the jiri binary, and copy the jiri shim
+# script from the sources.
+GOPATH="${tmp_dir}" go get -d v.io/jiri
+GOPATH="${tmp_dir}" go build -o "${bin_dir}/jiri" v.io/jiri
+cp "${tmp_dir}/src/v.io/jiri/scripts/jiri" "${scripts_dir}/jiri"
+
+# Clean up the tmp_dir.
+rm -rf "${tmp_dir}"
+
+echo "Please add ${scripts_dir} to your PATH."
+trap - EXIT
diff --git a/cmd/jiri/scripts/jiri b/cmd/jiri/scripts/jiri
new file mode 100755
index 0000000..6684669
--- /dev/null
+++ b/cmd/jiri/scripts/jiri
@@ -0,0 +1,61 @@
+#!/bin/bash
+# Copyright 2015 The Vanadium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file.
+
+# jiri is a shim script that determines the jiri root directory and invokes
+# $JIRI_ROOT/.jiri_root/bin/jiri with the given arguments.
+#
+# If the JIRI_ROOT environment variable is set, that is assumed to be the jiri
+# root directory.
+#
+# Otherwise the script looks for the .jiri_root directory, starting in the
+# current working directory and walking up the directory chain.  The search is
+# terminated successfully when the .jiri_root directory is found; it fails after
+# it reaches the root of the file system.
+#
+# This script should be invoked from the jiri root directory or one of its
+# subdirectories, unless the JIRI_ROOT environment variable is set.
+
+set -euf -o pipefail
+
+# fatal prints an error message and exits.
+fatal() {
+  echo "ERROR: $@" 1>&2
+  exit 1
+}
+
+# Handle "jiri which" without any arguments.  This is handy to determine whether
+# the PATH is set up pointing at this shim script, or a regular binary.  If
+# there are any arguments, we pass the command through to the binary.
+if [[ $# -eq 1  ]]; then
+  if [[ "$1" == "which" ]]; then
+    echo "# script"
+    type -p $0
+    exit 0
+  fi
+fi
+
+# If $JIRI_ROOT is set we always use it, otherwise look for a .jiri_root
+# directory starting with the current working directory, and walking up.
+if [[ -z ${JIRI_ROOT+x} ]]; then
+  CWD="$(pwd)"
+  while [[ ! -d  "$(pwd)/.jiri_root" ]]; do
+    if [[ "$(pwd)" == "/" ]]; then
+      fatal "could not find .jiri_root directory"
+    fi
+    cd ..
+  done
+  export JIRI_ROOT="$(pwd)"
+  cd "${CWD}"
+fi
+
+# Make sure the jiri binary exists and is executable.
+if [[ ! -e "${JIRI_ROOT}/.jiri_root/bin/jiri" ]]; then
+  fatal "${JIRI_ROOT}/.jiri_root/bin/jiri does not exist"
+elif [[ ! -x "${JIRI_ROOT}/.jiri_root/bin/jiri" ]]; then
+  fatal "${JIRI_ROOT}/.jiri_root/bin/jiri is not executable"
+fi
+
+# Execute the jiri binary.
+exec "${JIRI_ROOT}/.jiri_root/bin/jiri" "$@"
diff --git a/cmd/jiri/scripts/jiri-bash-completion.sh b/cmd/jiri/scripts/jiri-bash-completion.sh
new file mode 100644
index 0000000..92e7e27
--- /dev/null
+++ b/cmd/jiri/scripts/jiri-bash-completion.sh
@@ -0,0 +1,107 @@
+#!/bin/bash
+# Copyright 2015 The Vanadium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file.
+
+# Check for bash
+[[ -z "$BASH_VERSION" ]] && return
+
+# Extracts subcommands from the help output of a given command ($1).
+# An example help output from "jiri help":
+# ...
+# The jiri commands are:
+#    build        Tool for managing vanadium builds
+#    contributors List vanadium project contributors
+#    ...
+# Run "jiri help [command]" for command usage.
+# ...
+_jiri_extract_subcommands() {
+  local -r CMD="$1"
+  local -r START_LINE="The .* commands are"
+  local -r END_LINE="Run \"jiri"
+  ${CMD} -h > /dev/null 2>&1
+  if [[ "$?" = 0 ]]; then
+    ${CMD} -h \
+      | sed -n "/${START_LINE}/,/${END_LINE}/p" | grep '^   '| awk '{print $1}' | sort
+  fi
+}
+
+# Extracts flags from the help output of a given command ($1).
+# Note that we ignore all "global" flags.
+# An example help output from "jiri help update":
+# ...
+# The jiri update flags are:
+#  -gc=false
+#    Garbage collect obsolete repositories.
+#  -n=false
+# ...
+_jiri_extract_flags() {
+  local -r CMD="$1"
+  local -r START_LINE="The .* flags are"
+  local -r END_LINE="^$"
+  ${CMD} -h > /dev/null 2>&1
+  if [[ "$?" = 0 ]]; then
+    ${CMD} -h | sed -n "/${START_LINE}/,/${END_LINE}/p" | grep '^ -'| cut -d= -f1 | tr -d " " | sort
+  fi
+}
+
+# Gets the command line before the word where the current cursor is.
+_jiri_get_cmdline_before_cursor() {
+  local i
+  local CMD=""
+  for(( i=0; i<COMP_CWORD; i++ )); do
+    CMD="${CMD} ${COMP_WORDS[i]}"
+  done
+  echo "${CMD}"
+}
+
+# Gets the names of available projects.
+_jiri_get_project_names() {
+  jiri project list | sed "s/^.*project=\"\(.*\)\" path.*/\1/"
+}
+
+
+# Main bash completion function for the "jiri" command.
+#
+# Completion-related internal Bash vars:
+# - COMP_WORDS: An array variable consisting of the individual words in the
+#   current command line
+# - COMP_CWORD: An index into ${COMP_WORDS} of the word containing the current cursor
+#   position.
+# - COMPREPLY An array variable from which bash reads the possible completions
+#   generated by a completion function.
+_jiri_complete() {
+  local -r CUR="${COMP_WORDS[COMP_CWORD]}"
+  local -r CMD="$(_jiri_get_cmdline_before_cursor)"
+
+  case "${CUR}" in
+    -*)
+      # Complete flags.
+      COMPREPLY=($(compgen -W "$(_jiri_extract_flags "${CMD}")" -- ${CUR}))
+      ;;
+    *)
+      # Complete commands.
+      if [[ "${COMP_LINE}" =~ .*test(\ )+project.* ]]; then
+        # Special handling for completing "jiri test project".
+        COMPREPLY=($(compgen -W "$(_jiri_get_project_names)" -- ${CUR}))
+      elif [[ "${COMP_LINE}" =~ .*test(\ )+run.* ]]; then
+        # Special handling for completing "jiri test run".
+        COMPREPLY=($(compgen -W "$(jiri test list)" -- ${CUR}))
+      else
+        COMPREPLY=($(compgen -W "$(_jiri_extract_subcommands "${CMD}")" -- ${CUR}))
+      fi
+      ;;
+  esac
+
+  return 0
+}
+
+# Main bash completion function for the "vcd" command.
+_jiri_vcd_complete() {
+  local -r CUR="${COMP_WORDS[COMP_CWORD]}"
+
+  COMPREPLY=($(compgen -W "$(_jiri_get_project_names)" -- ${CUR}))
+}
+
+complete -F _jiri_complete jiri
+complete -F _jiri_vcd_complete vcd
diff --git a/cmd/jiri/snapshot.go b/cmd/jiri/snapshot.go
new file mode 100644
index 0000000..de89012
--- /dev/null
+++ b/cmd/jiri/snapshot.go
@@ -0,0 +1,300 @@
+// Copyright 2015 The Vanadium 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"
+	"io/ioutil"
+	"os"
+	"path/filepath"
+	"sort"
+	"strings"
+	"time"
+
+	"v.io/jiri/collect"
+	"v.io/jiri/gitutil"
+	"v.io/jiri/jiri"
+	"v.io/jiri/project"
+	"v.io/jiri/runutil"
+	"v.io/x/lib/cmdline"
+)
+
+const (
+	defaultSnapshotDir = ".snapshot"
+)
+
+var (
+	pushRemoteFlag  bool
+	snapshotDirFlag string
+	snapshotGcFlag  bool
+	timeFormatFlag  string
+)
+
+func init() {
+	cmdSnapshot.Flags.StringVar(&snapshotDirFlag, "dir", "", "Directory where snapshot are stored.  Defaults to $JIRI_ROOT/.snapshot.")
+	cmdSnapshotCheckout.Flags.BoolVar(&snapshotGcFlag, "gc", false, "Garbage collect obsolete repositories.")
+	cmdSnapshotCreate.Flags.BoolVar(&pushRemoteFlag, "push-remote", false, "Commit and push snapshot upstream.")
+	cmdSnapshotCreate.Flags.StringVar(&timeFormatFlag, "time-format", time.RFC3339, "Time format for snapshot file name.")
+}
+
+var cmdSnapshot = &cmdline.Command{
+	Name:  "snapshot",
+	Short: "Manage project snapshots",
+	Long: `
+The "jiri snapshot" command can be used to manage project snapshots.
+In particular, it can be used to create new snapshots and to list
+existing snapshots.
+`,
+	Children: []*cmdline.Command{cmdSnapshotCheckout, cmdSnapshotCreate, cmdSnapshotList},
+}
+
+// cmdSnapshotCreate represents the "jiri snapshot create" command.
+var cmdSnapshotCreate = &cmdline.Command{
+	Runner: jiri.RunnerFunc(runSnapshotCreate),
+	Name:   "create",
+	Short:  "Create a new project snapshot",
+	Long: `
+The "jiri snapshot create <label>" command captures the current project state
+in a manifest.  If the -push-remote flag is provided, the snapshot is committed
+and pushed upstream.
+
+Internally, snapshots are organized as follows:
+
+ <snapshot-dir>/
+   labels/
+     <label1>/
+       <label1-snapshot1>
+       <label1-snapshot2>
+       ...
+     <label2>/
+       <label2-snapshot1>
+       <label2-snapshot2>
+       ...
+     <label3>/
+     ...
+   <label1> # a symlink to the latest <label1-snapshot*>
+   <label2> # a symlink to the latest <label2-snapshot*>
+   ...
+
+NOTE: Unlike the jiri tool commands, the above internal organization
+is not an API. It is an implementation and can change without notice.
+`,
+	ArgsName: "<label>",
+	ArgsLong: "<label> is the snapshot label.",
+}
+
+func runSnapshotCreate(jirix *jiri.X, args []string) error {
+	if len(args) != 1 {
+		return jirix.UsageErrorf("unexpected number of arguments")
+	}
+	label := args[0]
+	snapshotDir, err := getSnapshotDir(jirix)
+	if err != nil {
+		return err
+	}
+	snapshotFile := filepath.Join(snapshotDir, "labels", label, time.Now().Format(timeFormatFlag))
+
+	if !pushRemoteFlag {
+		// No git operations necessary.  Just create the snapshot file.
+		return createSnapshot(jirix, snapshotDir, snapshotFile, label)
+	}
+
+	// Attempt to create a snapshot on a clean master branch.  If snapshot
+	// creation fails, return to the state we were in before.
+	createFn := func() error {
+		git := gitutil.New(jirix.NewSeq())
+		revision, err := git.CurrentRevision()
+		if err != nil {
+			return err
+		}
+		if err := createSnapshot(jirix, snapshotDir, snapshotFile, label); err != nil {
+			git.Reset(revision)
+			git.RemoveUntrackedFiles()
+			return err
+		}
+		return commitAndPushChanges(jirix, snapshotDir, snapshotFile, label)
+	}
+
+	// Execute the above function in the snapshot directory on a clean master branch.
+	p := project.Project{
+		Path:         snapshotDir,
+		Protocol:     "git",
+		RemoteBranch: "master",
+		Revision:     "HEAD",
+	}
+	return project.ApplyToLocalMaster(jirix, project.Projects{p.Key(): p}, createFn)
+}
+
+// getSnapshotDir returns the path to the snapshot directory, creating it if
+// necessary.
+func getSnapshotDir(jirix *jiri.X) (string, error) {
+	dir := snapshotDirFlag
+	if dir == "" {
+		dir = filepath.Join(jirix.Root, defaultSnapshotDir)
+	}
+
+	if !filepath.IsAbs(dir) {
+		cwd, err := os.Getwd()
+		if err != nil {
+			return "", err
+		}
+		dir = filepath.Join(cwd, dir)
+	}
+
+	// Make sure directory exists.
+	if err := jirix.NewSeq().MkdirAll(dir, 0755).Done(); err != nil {
+		return "", err
+	}
+	return dir, nil
+}
+
+func createSnapshot(jirix *jiri.X, snapshotDir, snapshotFile, label string) error {
+	// Create a snapshot that encodes the current state of master
+	// branches for all local projects.
+	if err := project.CreateSnapshot(jirix, snapshotFile); err != nil {
+		return err
+	}
+
+	s := jirix.NewSeq()
+	// Update the symlink for this snapshot label to point to the
+	// latest snapshot.
+	symlink := filepath.Join(snapshotDir, label)
+	newSymlink := symlink + ".new"
+	relativeSnapshotPath := strings.TrimPrefix(snapshotFile, snapshotDir+string(os.PathSeparator))
+	return s.RemoveAll(newSymlink).
+		Symlink(relativeSnapshotPath, newSymlink).
+		Rename(newSymlink, symlink).Done()
+}
+
+// commitAndPushChanges commits changes identified by the given manifest file
+// and label to the containing repository and pushes these changes to the
+// remote repository.
+func commitAndPushChanges(jirix *jiri.X, snapshotDir, snapshotFile, label string) (e error) {
+	cwd, err := os.Getwd()
+	if err != nil {
+		return err
+	}
+	defer collect.Error(func() error { return jirix.NewSeq().Chdir(cwd).Done() }, &e)
+	if err := jirix.NewSeq().Chdir(snapshotDir).Done(); err != nil {
+		return err
+	}
+	relativeSnapshotPath := strings.TrimPrefix(snapshotFile, snapshotDir+string(os.PathSeparator))
+	git := gitutil.New(jirix.NewSeq())
+	// Pull from master so we are up-to-date.
+	if err := git.Pull("origin", "master"); err != nil {
+		return err
+	}
+	if err := git.Add(relativeSnapshotPath); err != nil {
+		return err
+	}
+	if err := git.Add(label); err != nil {
+		return err
+	}
+	name := strings.TrimPrefix(snapshotFile, snapshotDir)
+	if err := git.CommitNoVerify(fmt.Sprintf("adding snapshot %q for label %q", name, label)); err != nil {
+		return err
+	}
+	if err := git.Push("origin", "master", gitutil.VerifyOpt(false)); err != nil {
+		return err
+	}
+	return nil
+}
+
+// cmdSnapshotCheckout represents the "jiri snapshot checkout" command.
+var cmdSnapshotCheckout = &cmdline.Command{
+	Runner: jiri.RunnerFunc(runSnapshotCheckout),
+	Name:   "checkout",
+	Short:  "Checkout a project snapshot",
+	Long: `
+The "jiri snapshot checkout <snapshot>" command restores local project state to
+the state in the given snapshot manifest.
+`,
+	ArgsName: "<snapshot>",
+	ArgsLong: "<snapshot> is the snapshot manifest file.",
+}
+
+func runSnapshotCheckout(jirix *jiri.X, args []string) error {
+	if len(args) != 1 {
+		return jirix.UsageErrorf("unexpected number of arguments")
+	}
+	return project.CheckoutSnapshot(jirix, args[0], snapshotGcFlag)
+}
+
+// cmdSnapshotList represents the "jiri snapshot list" command.
+var cmdSnapshotList = &cmdline.Command{
+	Runner: jiri.RunnerFunc(runSnapshotList),
+	Name:   "list",
+	Short:  "List existing project snapshots",
+	Long: `
+The "snapshot list" command lists existing snapshots of the labels
+specified as command-line arguments. If no arguments are provided, the
+command lists snapshots for all known labels.
+`,
+	ArgsName: "<label ...>",
+	ArgsLong: "<label ...> is a list of snapshot labels.",
+}
+
+func runSnapshotList(jirix *jiri.X, args []string) error {
+	snapshotDir, err := getSnapshotDir(jirix)
+	if err != nil {
+		return err
+	}
+	if len(args) == 0 {
+		// Identify all known snapshot labels, using a
+		// heuristic that looks for all symbolic links <foo>
+		// in the snapshot directory that point to a file in
+		// the "labels/<foo>" subdirectory of the snapshot
+		// directory.
+		fileInfoList, err := ioutil.ReadDir(snapshotDir)
+		if err != nil {
+			return fmt.Errorf("ReadDir(%v) failed: %v", snapshotDir, err)
+		}
+		for _, fileInfo := range fileInfoList {
+			if fileInfo.Mode()&os.ModeSymlink != 0 {
+				path := filepath.Join(snapshotDir, fileInfo.Name())
+				dst, err := filepath.EvalSymlinks(path)
+				if err != nil {
+					return fmt.Errorf("EvalSymlinks(%v) failed: %v", path, err)
+				}
+				if strings.HasSuffix(filepath.Dir(dst), filepath.Join("labels", fileInfo.Name())) {
+					args = append(args, fileInfo.Name())
+				}
+			}
+		}
+	}
+
+	// Check that all labels exist.
+	failed := false
+	for _, label := range args {
+		labelDir := filepath.Join(snapshotDir, "labels", label)
+		if _, err := jirix.NewSeq().Stat(labelDir); err != nil {
+			if !runutil.IsNotExist(err) {
+				return err
+			}
+			failed = true
+			fmt.Fprintf(jirix.Stderr(), "snapshot label %q not found", label)
+		}
+	}
+	if failed {
+		return cmdline.ErrExitCode(2)
+	}
+
+	// Print snapshots for all labels.
+	sort.Strings(args)
+	for _, label := range args {
+		// Scan the snapshot directory "labels/<label>" printing
+		// all snapshots.
+		labelDir := filepath.Join(snapshotDir, "labels", label)
+		fileInfoList, err := ioutil.ReadDir(labelDir)
+		if err != nil {
+			return fmt.Errorf("ReadDir(%v) failed: %v", labelDir, err)
+		}
+		fmt.Fprintf(jirix.Stdout(), "snapshots of label %q:\n", label)
+		for _, fileInfo := range fileInfoList {
+			fmt.Fprintf(jirix.Stdout(), "  %v\n", fileInfo.Name())
+		}
+	}
+	return nil
+}
diff --git a/cmd/jiri/snapshot_test.go b/cmd/jiri/snapshot_test.go
new file mode 100644
index 0000000..d24b5a6
--- /dev/null
+++ b/cmd/jiri/snapshot_test.go
@@ -0,0 +1,341 @@
+// Copyright 2015 The Vanadium 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"
+	"os"
+	"path/filepath"
+	"testing"
+
+	"v.io/jiri/gitutil"
+	"v.io/jiri/jiri"
+	"v.io/jiri/jiritest"
+	"v.io/jiri/project"
+	"v.io/jiri/tool"
+)
+
+func createLabelDir(t *testing.T, jirix *jiri.X, snapshotDir, name string, snapshots []string) {
+	if snapshotDir == "" {
+		snapshotDir = filepath.Join(jirix.Root, defaultSnapshotDir)
+	}
+	s := jirix.NewSeq()
+	labelDir, perm := filepath.Join(snapshotDir, "labels", name), os.FileMode(0700)
+	if err := s.MkdirAll(labelDir, perm).Done(); err != nil {
+		t.Fatalf("MkdirAll(%v, %v) failed: %v", labelDir, perm, err)
+	}
+	for i, snapshot := range snapshots {
+		path := filepath.Join(labelDir, snapshot)
+		_, err := os.Create(path)
+		if err != nil {
+			t.Fatalf("%v", err)
+		}
+		if i == 0 {
+			symlinkPath := filepath.Join(snapshotDir, name)
+			if err := s.Symlink(path, symlinkPath).Done(); err != nil {
+				t.Fatalf("Symlink(%v, %v) failed: %v", path, symlinkPath, err)
+			}
+		}
+	}
+}
+
+func generateOutput(labels []label) string {
+	output := ""
+	for _, label := range labels {
+		output += fmt.Sprintf("snapshots of label %q:\n", label.name)
+		for _, snapshot := range label.snapshots {
+			output += fmt.Sprintf("  %v\n", snapshot)
+		}
+	}
+	return output
+}
+
+type config struct {
+	remote bool
+	dir    string
+}
+
+type label struct {
+	name      string
+	snapshots []string
+}
+
+func TestList(t *testing.T) {
+	resetFlags()
+	fake, cleanup := jiritest.NewFakeJiriRoot(t)
+	defer cleanup()
+
+	snapshotDir1 := "" // Should use default dir.
+	snapshotDir2 := filepath.Join(fake.X.Root, "some/other/dir")
+
+	// Create a test suite.
+	tests := []config{
+		config{
+			dir: snapshotDir1,
+		},
+		config{
+			dir: snapshotDir2,
+		},
+	}
+	labels := []label{
+		label{
+			name:      "beta",
+			snapshots: []string{"beta-1", "beta-2", "beta-3"},
+		},
+		label{
+			name:      "stable",
+			snapshots: []string{"stable-1", "stable-2", "stable-3"},
+		},
+	}
+
+	for _, test := range tests {
+		snapshotDirFlag = test.dir
+		// Create the snapshots directory and populate it with the
+		// data specified by the test suite.
+		for _, label := range labels {
+			createLabelDir(t, fake.X, test.dir, label.name, label.snapshots)
+		}
+
+		// Check that running "jiri snapshot list" with no arguments
+		// returns the expected output.
+		var stdout bytes.Buffer
+		fake.X.Context = tool.NewContext(tool.ContextOpts{Stdout: &stdout})
+		if err := runSnapshotList(fake.X, nil); err != nil {
+			t.Fatalf("%v", err)
+		}
+		got, want := stdout.String(), generateOutput(labels)
+		if got != want {
+			t.Fatalf("unexpected output:\ngot\n%v\nwant\n%v\n", got, want)
+		}
+
+		// Check that running "jiri snapshot list" with one argument
+		// returns the expected output.
+		stdout.Reset()
+		if err := runSnapshotList(fake.X, []string{"stable"}); err != nil {
+			t.Fatalf("%v", err)
+		}
+		got, want = stdout.String(), generateOutput(labels[1:])
+		if got != want {
+			t.Fatalf("unexpected output:\ngot\n%v\nwant\n%v\n", got, want)
+		}
+
+		// Check that running "jiri snapshot list" with
+		// multiple arguments returns the expected output.
+		stdout.Reset()
+		if err := runSnapshotList(fake.X, []string{"beta", "stable"}); err != nil {
+			t.Fatalf("%v", err)
+		}
+		got, want = stdout.String(), generateOutput(labels)
+		if got != want {
+			t.Fatalf("unexpected output:\ngot\n%v\nwant\n%v\n", got, want)
+		}
+	}
+}
+
+func checkReadme(t *testing.T, jirix *jiri.X, project, message string) {
+	s := jirix.NewSeq()
+	if _, err := s.Stat(project); err != nil {
+		t.Fatalf("%v", err)
+	}
+	readmeFile := filepath.Join(project, "README")
+	data, err := s.ReadFile(readmeFile)
+	if err != nil {
+		t.Fatalf("%v", err)
+	}
+	if got, want := data, []byte(message); bytes.Compare(got, want) != 0 {
+		t.Fatalf("unexpected content %v:\ngot\n%s\nwant\n%s\n", project, got, want)
+	}
+}
+
+func localProjectName(i int) string {
+	return "test-local-project-" + fmt.Sprintf("%d", i+1)
+}
+
+func remoteProjectName(i int) string {
+	return "test-remote-project-" + fmt.Sprintf("%d", i+1)
+}
+
+func writeReadme(t *testing.T, jirix *jiri.X, projectDir, message string) {
+	s := jirix.NewSeq()
+	path, perm := filepath.Join(projectDir, "README"), os.FileMode(0644)
+	if err := s.WriteFile(path, []byte(message), perm).Done(); err != nil {
+		t.Fatalf("%v", err)
+	}
+	cwd, err := os.Getwd()
+	if err != nil {
+		t.Fatalf("%v", err)
+	}
+	defer jirix.NewSeq().Chdir(cwd)
+	if err := s.Chdir(projectDir).Done(); err != nil {
+		t.Fatalf("%v", err)
+	}
+	if err := gitutil.New(jirix.NewSeq()).CommitFile(path, "creating README"); err != nil {
+		t.Fatalf("%v", err)
+	}
+}
+
+func resetFlags() {
+	snapshotDirFlag = ""
+	pushRemoteFlag = false
+}
+
+func TestGetSnapshotDir(t *testing.T) {
+	resetFlags()
+	defer resetFlags()
+	fake, cleanup := jiritest.NewFakeJiriRoot(t)
+	defer cleanup()
+
+	// With all flags at default values, snapshot dir should be default.
+	resetFlags()
+	got, err := getSnapshotDir(fake.X)
+	if err != nil {
+		t.Fatalf("getSnapshotDir() failed: %v\n", err)
+	}
+	if want := filepath.Join(fake.X.Root, defaultSnapshotDir); got != want {
+		t.Errorf("unexpected snapshot dir: got %v want %v", got, want)
+	}
+
+	// With dir flag set to absolute path, snapshot dir should be value of dir
+	// flag.
+	resetFlags()
+	tempDir, err := fake.X.NewSeq().TempDir("", "")
+	if err != nil {
+		t.Fatalf("TempDir() failed: %v", err)
+	}
+	defer fake.X.NewSeq().RemoveAll(tempDir).Done()
+	snapshotDirFlag = tempDir
+	got, err = getSnapshotDir(fake.X)
+	if err != nil {
+		t.Fatalf("getSnapshotDir() failed: %v\n", err)
+	}
+	if want := snapshotDirFlag; got != want {
+		t.Errorf("unexpected snapshot dir: got %v want %v", got, want)
+	}
+
+	// With dir flag set to relative path, snapshot dir should absolute path
+	// rooted at current working dir.
+	resetFlags()
+	snapshotDirFlag = "some/relative/path"
+	cwd, err := os.Getwd()
+	if err != nil {
+		t.Fatalf("os.Getwd() failed: %v", err)
+	}
+	got, err = getSnapshotDir(fake.X)
+	if err != nil {
+		t.Fatalf("getSnapshotDir() failed: %v\n", err)
+	}
+	if want := filepath.Join(cwd, snapshotDirFlag); got != want {
+		t.Errorf("unexpected snapshot dir: got %v want %v", got, want)
+	}
+}
+
+// TestCreate tests creating and checking out a snapshot.
+func TestCreate(t *testing.T) {
+	resetFlags()
+	defer resetFlags()
+	fake, cleanup := jiritest.NewFakeJiriRoot(t)
+	defer cleanup()
+	s := fake.X.NewSeq()
+
+	// Setup the initial remote and local projects.
+	numProjects, remoteProjects := 2, []string{}
+	for i := 0; i < numProjects; i++ {
+		if err := fake.CreateRemoteProject(remoteProjectName(i)); err != nil {
+			t.Fatalf("%v", err)
+		}
+		if err := fake.AddProject(project.Project{
+			Name:   remoteProjectName(i),
+			Path:   localProjectName(i),
+			Remote: fake.Projects[remoteProjectName(i)],
+		}); err != nil {
+			t.Fatalf("%v", err)
+		}
+	}
+
+	// Create initial commits in the remote projects and use UpdateUniverse()
+	// to mirror them locally.
+	for i := 0; i < numProjects; i++ {
+		writeReadme(t, fake.X, fake.Projects[remoteProjectName(i)], "revision 1")
+	}
+	if err := project.UpdateUniverse(fake.X, true); err != nil {
+		t.Fatalf("%v", err)
+	}
+
+	// Create a snapshot.
+	var stdout bytes.Buffer
+	fake.X.Context = tool.NewContext(tool.ContextOpts{Stdout: &stdout})
+	if err := runSnapshotCreate(fake.X, []string{"test-local"}); err != nil {
+		t.Fatalf("%v", err)
+	}
+
+	// Remove the local project repositories.
+	for i, _ := range remoteProjects {
+		localProject := filepath.Join(fake.X.Root, localProjectName(i))
+		if err := s.RemoveAll(localProject).Done(); err != nil {
+			t.Fatalf("%v", err)
+		}
+	}
+
+	// Check that invoking the UpdateUniverse() with the snapshot restores the
+	// local repositories.
+	snapshotDir := filepath.Join(fake.X.Root, defaultSnapshotDir)
+	snapshotFile := filepath.Join(snapshotDir, "test-local")
+	localX := fake.X.Clone(tool.ContextOpts{
+		Manifest: &snapshotFile,
+	})
+	if err := project.UpdateUniverse(localX, true); err != nil {
+		t.Fatalf("%v", err)
+	}
+	for i, _ := range remoteProjects {
+		localProject := filepath.Join(fake.X.Root, localProjectName(i))
+		checkReadme(t, fake.X, localProject, "revision 1")
+	}
+}
+
+// TestCreatePushRemote checks that creating a snapshot with the -push-remote
+// flag causes the snapshot to be committed and pushed upstream.
+func TestCreatePushRemote(t *testing.T) {
+	resetFlags()
+	defer resetFlags()
+
+	fake, cleanup := jiritest.NewFakeJiriRoot(t)
+	defer cleanup()
+
+	fake.EnableRemoteManifestPush()
+	defer fake.DisableRemoteManifestPush()
+
+	manifestDir := filepath.Join(fake.X.Root, ".manifest")
+	snapshotDir := filepath.Join(manifestDir, "snapshot")
+	label := "test"
+
+	git := gitutil.New(fake.X.NewSeq(), gitutil.RootDirOpt(manifestDir))
+	commitCount, err := git.CountCommits("master", "")
+	if err != nil {
+		t.Fatalf("git.CountCommits(\"master\", \"\") failed: %v", err)
+	}
+
+	// Create snapshot with -push-remote flag set to true.
+	snapshotDirFlag = snapshotDir
+	pushRemoteFlag = true
+	if err := runSnapshotCreate(fake.X, []string{label}); err != nil {
+		t.Fatalf("%v", err)
+	}
+
+	// Check that repo has one new commit.
+	newCommitCount, err := git.CountCommits("master", "")
+	if err != nil {
+		t.Fatalf("git.CountCommits(\"master\", \"\") failed: %v", err)
+	}
+	if got, want := newCommitCount, commitCount+1; got != want {
+		t.Errorf("unexpected commit count: got %v want %v", got, want)
+	}
+
+	// Check that new label is commited.
+	labelFile := filepath.Join(snapshotDir, "labels", label)
+	if !git.IsFileCommitted(labelFile) {
+		t.Errorf("expected file %v to be committed but it was not", labelFile)
+	}
+}
diff --git a/cmd/jiri/update.go b/cmd/jiri/update.go
new file mode 100644
index 0000000..f87107d
--- /dev/null
+++ b/cmd/jiri/update.go
@@ -0,0 +1,67 @@
+// Copyright 2015 The Vanadium 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 (
+	"v.io/jiri/jiri"
+	"v.io/jiri/project"
+	"v.io/jiri/retry"
+	"v.io/jiri/tool"
+	"v.io/x/lib/cmdline"
+)
+
+var (
+	gcFlag       bool
+	attemptsFlag int
+)
+
+func init() {
+	tool.InitializeProjectFlags(&cmdUpdate.Flags)
+
+	cmdUpdate.Flags.BoolVar(&gcFlag, "gc", false, "Garbage collect obsolete repositories.")
+	cmdUpdate.Flags.IntVar(&attemptsFlag, "attempts", 1, "Number of attempts before failing.")
+}
+
+// cmdUpdate represents the "jiri update" command.
+var cmdUpdate = &cmdline.Command{
+	Runner: jiri.RunnerFunc(runUpdate),
+	Name:   "update",
+	Short:  "Update all jiri tools and projects",
+	Long: `
+Updates all projects, builds the latest version of all tools, and installs the
+resulting binaries into $JIRI_ROOT/.jiri_root/bin. The sequence in which the
+individual updates happen guarantees that we end up with a consistent set of
+tools and source code. The set of projects and tools to update is described in
+the manifest.
+
+Run "jiri help manifest" for details on manifests.
+`,
+}
+
+func runUpdate(jirix *jiri.X, _ []string) error {
+	seq := jirix.NewSeq()
+	// Create the $JIRI_ROOT/.jiri_root directory if it doesn't already exist.
+	//
+	// TODO(toddw): Remove this logic after the transition to .jiri_root is done.
+	// The bootstrapping logic should create this directory, and jiri should fail
+	// if the directory doesn't exist.
+	if err := seq.MkdirAll(jirix.RootMetaDir(), 0755).Done(); err != nil {
+		return err
+	}
+
+	// Update all projects to their latest version.
+	// Attempt <attemptsFlag> times before failing.
+	updateFn := func() error { return project.UpdateUniverse(jirix, gcFlag) }
+	if err := retry.Function(jirix.Context, updateFn, retry.AttemptsOpt(attemptsFlag)); err != nil {
+		return err
+	}
+	if err := project.WriteUpdateHistorySnapshot(jirix); err != nil {
+		return err
+	}
+
+	// Only attempt the bin dir transition after the update has succeeded, to
+	// avoid messy partial states.
+	return project.TransitionBinDir(jirix)
+}
diff --git a/cmd/jiri/upgrade.go b/cmd/jiri/upgrade.go
new file mode 100644
index 0000000..da6947f
--- /dev/null
+++ b/cmd/jiri/upgrade.go
@@ -0,0 +1,150 @@
+// Copyright 2015 The Vanadium 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"
+	"path/filepath"
+
+	"v.io/jiri/jiri"
+	"v.io/jiri/project"
+	"v.io/jiri/runutil"
+	"v.io/x/lib/cmdline"
+)
+
+// TODO(toddw): Remove the upgrade command after the transition to new-style
+// manifests is complete.
+
+var flagUpgradeRevert bool
+
+func init() {
+	cmdUpgrade.Flags.BoolVar(&flagUpgradeRevert, "revert", false, `Revert the upgrade by deleting the $JIRI_ROOT/.jiri_manifest file.`)
+}
+
+var cmdUpgrade = &cmdline.Command{
+	Runner: jiri.RunnerFunc(runUpgrade),
+	Name:   "upgrade",
+	Short:  "Upgrade jiri to new-style manifests",
+	Long: `
+Upgrades jiri to use new-style manifests.
+
+The old (deprecated) behavior only allowed a single manifest repository, located
+in $JIRI_ROOT/.manifest.  The initial manifest file is located as follows:
+  1) Use -manifest flag, if non-empty.  If it's empty...
+  2) Use $JIRI_ROOT/.local_manifest file.  If it doesn't exist...
+  3) Use $JIRI_ROOT/.manifest/v2/default.
+
+The new behavior allows multiple manifest repositories, by allowing imports to
+specify project attributes describing the remote repository.  The -manifest flag
+is no longer allowed to be set; the initial manifest file is always located in
+$JIRI_ROOT/.jiri_manifest.  The .local_manifest file is ignored.
+
+During the transition phase, both old and new behaviors are supported.  The jiri
+tool uses the existence of the $JIRI_ROOT/.jiri_manifest file as the signal; if
+it exists we run the new behavior, otherwise we run the old behavior.
+
+The new behavior includes a "jiri import" command, which writes or updates the
+.jiri_manifest file.  The new bootstrap procedure runs "jiri import", and it is
+intended as a regular command to add imports to your jiri environment.
+
+This upgrade command eases the transition by writing an initial .jiri_manifest
+file for you.  If you have an existing .local_manifest file, its contents will
+be incorporated into the new .jiri_manifest file, and it will be renamed to
+.local_manifest.BACKUP.  The -revert flag deletes the .jiri_manifest file, and
+restores the .local_manifest file.
+`,
+	ArgsName: "<kind>",
+	ArgsLong: `
+<kind> specifies the kind of upgrade, one of "v23" or "fuchsia".
+`,
+}
+
+func runUpgrade(jirix *jiri.X, args []string) error {
+	localFile := filepath.Join(jirix.Root, ".local_manifest")
+	backupFile := localFile + ".BACKUP"
+	if flagUpgradeRevert {
+		// Restore .local_manifest.BACKUP if it exists.
+		switch _, err := jirix.NewSeq().Stat(backupFile); {
+		case err != nil && !runutil.IsNotExist(err):
+			return err
+		case err == nil:
+			if err := jirix.NewSeq().Rename(backupFile, localFile).Done(); err != nil {
+				return fmt.Errorf("couldn't restore %v to %v: %v", backupFile, localFile, err)
+			}
+		}
+		// Deleting the .jiri_manifest file reverts to the old behavior.
+		return jirix.NewSeq().Remove(jirix.JiriManifestFile()).Done()
+	}
+	if len(args) != 1 {
+		return jirix.UsageErrorf("must specify upgrade kind")
+	}
+	kind := args[0]
+	var argRemote, argName, argManifest string
+	switch kind {
+	case "v23":
+		argRemote = "https://vanadium.googlesource.com/manifest"
+		argName, argManifest = "manifest", "public"
+	case "fuchsia":
+		argRemote = "https://fuchsia.googlesource.com/fnl-start"
+		argName, argManifest = "fnl-start", "manifest/fuchsia"
+	default:
+		return jirix.UsageErrorf("unknown upgrade kind %q", kind)
+	}
+	// Initialize manifest from .local_manifest.
+	hasLocalFile := true
+	manifest, err := project.ManifestFromFile(jirix, localFile)
+	if err != nil {
+		if !runutil.IsNotExist(err) {
+			return err
+		}
+		hasLocalFile = false
+		manifest = &project.Manifest{}
+	}
+	oldImports := manifest.Imports
+	manifest.Imports = nil
+	for _, oldImport := range oldImports {
+		if oldImport.Remote != "" {
+			// This is a new-style remote import, carry it over directly.
+			manifest.Imports = append(manifest.Imports, oldImport)
+			continue
+		}
+		// This is an old-style import, convert it to the new style.
+		oldName := oldImport.Name
+		switch {
+		case kind == "v23" && oldName == "default":
+			oldName = "public"
+		case kind == "fuchsia" && oldName == "default":
+			oldName = "manifest/fuchsia"
+		}
+		manifest.Imports = append(manifest.Imports, project.Import{
+			Manifest: oldName,
+			Name:     argName,
+			Remote:   argRemote,
+		})
+	}
+	if len(manifest.Imports) == 0 {
+		manifest.Imports = append(manifest.Imports, project.Import{
+			Manifest: argManifest,
+			Name:     argName,
+			Remote:   argRemote,
+		})
+	}
+	// Write output to .jiri_manifest file.
+	outFile := jirix.JiriManifestFile()
+	if _, err := os.Stat(outFile); err == nil {
+		return fmt.Errorf("%v already exists", outFile)
+	}
+	if err := manifest.ToFile(jirix, outFile); err != nil {
+		return err
+	}
+	// Backup .local_manifest file, if it exists.
+	if hasLocalFile {
+		if err := jirix.NewSeq().Rename(localFile, backupFile).Done(); err != nil {
+			return fmt.Errorf("couldn't backup %v to %v: %v", localFile, backupFile, err)
+		}
+	}
+	return nil
+}
diff --git a/cmd/jiri/upgrade_test.go b/cmd/jiri/upgrade_test.go
new file mode 100644
index 0000000..a7c5eab
--- /dev/null
+++ b/cmd/jiri/upgrade_test.go
@@ -0,0 +1,311 @@
+// Copyright 2015 The Vanadium 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"
+	"io/ioutil"
+	"os"
+	"strings"
+	"testing"
+
+	"v.io/jiri/jiri"
+	"v.io/x/lib/gosh"
+)
+
+type upgradeTestCase struct {
+	Args        []string
+	Exist       bool
+	Local, Want string
+	Stderr      string
+}
+
+func TestUpgrade(t *testing.T) {
+	tests := []upgradeTestCase{
+		{
+			Stderr: `must specify upgrade kind`,
+		},
+		{
+			Args:   []string{"foo"},
+			Stderr: `unknown upgrade kind "foo"`,
+		},
+		// Test v23 upgrades.
+		{
+			Args:   []string{"v23"},
+			Exist:  true,
+			Stderr: `.jiri_manifest already exists`,
+		},
+		{
+			Args: []string{"v23"},
+			Want: `<manifest>
+  <imports>
+    <import manifest="public" name="manifest" remote="https://vanadium.googlesource.com/manifest"/>
+  </imports>
+</manifest>
+`,
+		},
+		{
+			Args: []string{"v23"},
+			Local: `<manifest>
+  <imports>
+    <import name="default"/>
+  </imports>
+</manifest>
+`,
+			Want: `<manifest>
+  <imports>
+    <import manifest="public" name="manifest" remote="https://vanadium.googlesource.com/manifest"/>
+  </imports>
+</manifest>
+`,
+		},
+		{
+			Args: []string{"v23"},
+			Local: `<manifest>
+  <imports>
+    <import name="private"/>
+  </imports>
+</manifest>
+`,
+			Want: `<manifest>
+  <imports>
+    <import manifest="private" name="manifest" remote="https://vanadium.googlesource.com/manifest"/>
+  </imports>
+</manifest>
+`,
+		},
+		{
+			Args: []string{"v23"},
+			Local: `<manifest>
+  <imports>
+    <import name="private"/>
+    <import name="infrastructure"/>
+    <import name="default"/>
+  </imports>
+</manifest>
+`,
+			Want: `<manifest>
+  <imports>
+    <import manifest="private" name="manifest" remote="https://vanadium.googlesource.com/manifest"/>
+    <import manifest="infrastructure" name="manifest" remote="https://vanadium.googlesource.com/manifest"/>
+    <import manifest="public" name="manifest" remote="https://vanadium.googlesource.com/manifest"/>
+  </imports>
+</manifest>
+`,
+		},
+		{
+			Args: []string{"v23"},
+			Local: `<manifest>
+  <imports>
+    <import name="default"/>
+    <import name="infrastructure"/>
+    <import name="private"/>
+  </imports>
+</manifest>
+`,
+			Want: `<manifest>
+  <imports>
+    <import manifest="public" name="manifest" remote="https://vanadium.googlesource.com/manifest"/>
+    <import manifest="infrastructure" name="manifest" remote="https://vanadium.googlesource.com/manifest"/>
+    <import manifest="private" name="manifest" remote="https://vanadium.googlesource.com/manifest"/>
+  </imports>
+</manifest>
+`,
+		},
+		// Test fuchsia upgrades.
+		{
+			Args:   []string{"fuchsia"},
+			Exist:  true,
+			Stderr: `.jiri_manifest already exists`,
+		},
+		{
+			Args: []string{"fuchsia"},
+			Want: `<manifest>
+  <imports>
+    <import manifest="manifest/fuchsia" name="fnl-start" remote="https://fuchsia.googlesource.com/fnl-start"/>
+  </imports>
+</manifest>
+`,
+		},
+		{
+			Args: []string{"fuchsia"},
+			Local: `<manifest>
+  <imports>
+    <import name="default"/>
+  </imports>
+</manifest>
+`,
+			Want: `<manifest>
+  <imports>
+    <import manifest="manifest/fuchsia" name="fnl-start" remote="https://fuchsia.googlesource.com/fnl-start"/>
+  </imports>
+</manifest>
+`,
+		},
+		{
+			Args: []string{"fuchsia"},
+			Local: `<manifest>
+  <imports>
+    <import name="private"/>
+  </imports>
+</manifest>
+`,
+			Want: `<manifest>
+  <imports>
+    <import manifest="private" name="fnl-start" remote="https://fuchsia.googlesource.com/fnl-start"/>
+  </imports>
+</manifest>
+`,
+		},
+		{
+			Args: []string{"fuchsia"},
+			Local: `<manifest>
+  <imports>
+    <import name="private"/>
+    <import name="infrastructure"/>
+    <import name="default"/>
+  </imports>
+</manifest>
+`,
+			Want: `<manifest>
+  <imports>
+    <import manifest="private" name="fnl-start" remote="https://fuchsia.googlesource.com/fnl-start"/>
+    <import manifest="infrastructure" name="fnl-start" remote="https://fuchsia.googlesource.com/fnl-start"/>
+    <import manifest="manifest/fuchsia" name="fnl-start" remote="https://fuchsia.googlesource.com/fnl-start"/>
+  </imports>
+</manifest>
+`,
+		},
+		{
+			Args: []string{"fuchsia"},
+			Local: `<manifest>
+  <imports>
+    <import name="default"/>
+    <import name="infrastructure"/>
+    <import name="private"/>
+  </imports>
+</manifest>
+`,
+			Want: `<manifest>
+  <imports>
+    <import manifest="manifest/fuchsia" name="fnl-start" remote="https://fuchsia.googlesource.com/fnl-start"/>
+    <import manifest="infrastructure" name="fnl-start" remote="https://fuchsia.googlesource.com/fnl-start"/>
+    <import manifest="private" name="fnl-start" remote="https://fuchsia.googlesource.com/fnl-start"/>
+  </imports>
+</manifest>
+`,
+		},
+	}
+	opts := gosh.Opts{Fatalf: t.Fatalf, Logf: t.Logf}
+	sh := gosh.NewShell(opts)
+	defer sh.Cleanup()
+	jiriTool := sh.BuildGoPkg("v.io/jiri")
+	for _, test := range tests {
+		if err := testUpgrade(opts, jiriTool, test); err != nil {
+			t.Errorf("%v: %v", test.Args, err)
+		}
+	}
+}
+
+func testUpgrade(opts gosh.Opts, jiriTool string, test upgradeTestCase) error {
+	opts.PropagateChildOutput = true
+	sh := gosh.NewShell(opts)
+	defer sh.Cleanup()
+	jiriRoot := sh.MakeTempDir()
+	sh.Pushd(jiriRoot)
+	// Set up an existing file or local_manifest, if they were specified
+	if test.Exist {
+		if err := ioutil.WriteFile(".jiri_manifest", []byte("<manifest/>"), 0644); err != nil {
+			return err
+		}
+	}
+	if test.Local != "" {
+		if err := ioutil.WriteFile(".local_manifest", []byte(test.Local), 0644); err != nil {
+			return err
+		}
+	}
+	// Run upgrade and check the error.
+	sh.Vars[jiri.RootEnv] = jiriRoot
+	cmd := sh.Cmd(jiriTool, append([]string{"upgrade"}, test.Args...)...)
+	if test.Stderr != "" {
+		cmd.ExitErrorIsOk = true
+	}
+	_, stderr := cmd.StdoutStderr()
+	if got, want := stderr, test.Stderr; !strings.Contains(got, want) || (got != "" && want == "") {
+		return fmt.Errorf("stderr got %q, want substr %q", got, want)
+	}
+	// Make sure the right file is generated.
+	if test.Want != "" {
+		data, err := ioutil.ReadFile(".jiri_manifest")
+		if err != nil {
+			return err
+		}
+		if got, want := string(data), test.Want; got != want {
+			return fmt.Errorf("GOT\n%s\nWANT\n%s", got, want)
+		}
+	}
+	// Make sure the .local_manifest file is backed up.
+	if test.Local != "" && test.Stderr == "" {
+		data, err := ioutil.ReadFile(".local_manifest.BACKUP")
+		if err != nil {
+			return fmt.Errorf("local manifest backup got error: %v", err)
+		}
+		if got, want := string(data), test.Local; got != want {
+			return fmt.Errorf("local manifest backup GOT\n%s\nWANT\n%s", got, want)
+		}
+	}
+	return nil
+}
+
+func TestUpgradeRevert(t *testing.T) {
+	sh := gosh.NewShell(gosh.Opts{Fatalf: t.Fatalf, Logf: t.Logf})
+	defer sh.Cleanup()
+	jiriRoot := sh.MakeTempDir()
+	sh.Pushd(jiriRoot)
+	jiriTool := sh.BuildGoPkg("v.io/jiri")
+	localData := `<manifest/>`
+	jiriData := `<manifest>
+  <imports>
+    <import manifest="public" name="manifest" remote="https://vanadium.googlesource.com/manifest"/>
+  </imports>
+</manifest>
+`
+	// Set up an existing local_manifest.
+	if err := ioutil.WriteFile(".local_manifest", []byte(localData), 0644); err != nil {
+		t.Errorf("couldn't write local manifest: %v", err)
+	}
+	// Run a regular upgrade first, and make sure files are as expected.
+	sh.Vars[jiri.RootEnv] = jiriRoot
+	sh.Cmd(jiriTool, "upgrade", "v23").Run()
+	gotJiri, err := ioutil.ReadFile(".jiri_manifest")
+	if err != nil {
+		t.Errorf("couldn't read jiri manifest: %v", err)
+	}
+	if got, want := string(gotJiri), jiriData; got != want {
+		t.Errorf("jiri manifest GOT\n%s\nWANT\n%s", got, want)
+	}
+	gotBackup, err := ioutil.ReadFile(".local_manifest.BACKUP")
+	if err != nil {
+		t.Errorf("couldn't read local manifest backup: %v", err)
+	}
+	if got, want := string(gotBackup), localData; got != want {
+		t.Errorf("local manifest backup GOT\n%s\nWANT\n%s", got, want)
+	}
+	// Now run a revert, and make sure files are as expected.
+	sh.Cmd(jiriTool, "upgrade", "-revert").Run()
+	if _, err := os.Stat(".jiri_manifest"); !os.IsNotExist(err) {
+		t.Errorf(".jiri_manifest still exists after revert: %v", err)
+	}
+	if _, err := os.Stat(".local_manifest.BACKUP"); !os.IsNotExist(err) {
+		t.Errorf(".local_manifest.BACKUP still exists after revert: %v", err)
+	}
+	gotLocal, err := ioutil.ReadFile(".local_manifest")
+	if err != nil {
+		t.Errorf("couldn't read local manifest: %v", err)
+	}
+	if got, want := string(gotLocal), localData; got != want {
+		t.Errorf("local manifest GOT\n%s\nWANT\n%s", got, want)
+	}
+}
diff --git a/cmd/jiri/which.go b/cmd/jiri/which.go
new file mode 100644
index 0000000..9dee05c
--- /dev/null
+++ b/cmd/jiri/which.go
@@ -0,0 +1,54 @@
+// Copyright 2015 The Vanadium 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"
+	"os/exec"
+	"path/filepath"
+
+	"v.io/x/lib/cmdline"
+)
+
+var cmdWhich = &cmdline.Command{
+	Runner: cmdline.RunnerFunc(runWhich),
+	Name:   "which",
+	Short:  "Show path to the jiri tool",
+	Long: `
+Which behaves similarly to the unix commandline tool.  It is useful in
+determining whether the jiri binary is being run directly, or run via the jiri
+shim script.
+
+If the binary is being run directly, the output looks like this:
+
+  # binary
+  /path/to/binary/jiri
+
+If the script is being run, the output looks like this:
+
+  # script
+  /path/to/script/jiri
+`,
+}
+
+func runWhich(env *cmdline.Env, args []string) error {
+	if len(args) == 0 {
+		fmt.Fprintln(env.Stdout, "# binary")
+		path, err := exec.LookPath(os.Args[0])
+		if err != nil {
+			return err
+		}
+		abs, err := filepath.Abs(path)
+		if err != nil {
+			return err
+		}
+		fmt.Fprintln(env.Stdout, abs)
+		return nil
+	}
+	// TODO(toddw): Look up the path to each argument.  This will only be helpful
+	// after the profiles are moved back into the main jiri tool.
+	return fmt.Errorf("unexpected arguments")
+}
diff --git a/cmd/jiri/which_test.go b/cmd/jiri/which_test.go
new file mode 100644
index 0000000..dbe46f1
--- /dev/null
+++ b/cmd/jiri/which_test.go
@@ -0,0 +1,45 @@
+// Copyright 2015 The Vanadium 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"
+	"path/filepath"
+	"testing"
+
+	"v.io/x/lib/gosh"
+)
+
+func TestWhich(t *testing.T) {
+	sh := gosh.NewShell(gosh.Opts{Fatalf: t.Fatalf, Logf: t.Logf, PropagateChildOutput: true})
+	defer sh.Cleanup()
+
+	jiriBinary := sh.BuildGoPkg("v.io/jiri")
+	stdout, stderr := sh.Cmd(jiriBinary, []string{"which"}...).StdoutStderr()
+	if got, want := stdout, fmt.Sprintf("# binary\n%s\n", jiriBinary); got != want {
+		t.Errorf("stdout got %q, want %q", got, want)
+	}
+	if got, want := stderr, ""; got != want {
+		t.Errorf("stderr got %q, want %q", got, want)
+	}
+}
+
+// TestWhichScript tests the behavior of "jiri which" for the shim script.
+func TestWhichScript(t *testing.T) {
+	sh := gosh.NewShell(gosh.Opts{Fatalf: t.Fatalf, Logf: t.Logf, PropagateChildOutput: true})
+	defer sh.Cleanup()
+
+	jiriScript, err := filepath.Abs("./scripts/jiri")
+	if err != nil {
+		t.Fatalf("couldn't determine absolute path to jiri script")
+	}
+	stdout, stderr := sh.Cmd(jiriScript, "which").StdoutStderr()
+	if got, want := stdout, fmt.Sprintf("# script\n%s\n", jiriScript); got != want {
+		t.Errorf("stdout got %q, want %q", got, want)
+	}
+	if got, want := stderr, ""; got != want {
+		t.Errorf("stderr got %q, want %q", got, want)
+	}
+}