jiri: Add remote imports logic, and jiri import and upgrade cmds.

This CL adds the ability to import multiple manifest
repositories, via a mechanism called "remote imports".  In
addition it adds new commands "jiri import" and "jiri upgrade",
which help manage things in the new world.  The usage of "jiri
upgrade" explains what's happening:

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 -revert
flag simply deletes the .jiri_manifest file.

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.

The 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.

MultiPart: 1/2

Change-Id: I26bfc1d0928ad3979bdf1ea4bf8238e9b081f0c9
diff --git a/cmd.go b/cmd.go
index 863596b..afcbc6e 100644
--- a/cmd.go
+++ b/cmd.go
@@ -35,19 +35,21 @@
 	Children: []*cmdline.Command{
 		cmdCL,
 		cmdContributors,
+		cmdImport,
 		cmdProject,
 		cmdRebuild,
 		cmdSnapshot,
 		cmdUpdate,
+		cmdUpgrade,
 	},
 	Topics: []cmdline.Topic{
-		topicLayout,
+		topicFileSystem,
 		topicManifest,
 	},
 }
 
-var topicLayout = cmdline.Topic{
-	Name:  "layout",
+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
@@ -111,6 +113,7 @@
 `,
 }
 
+// TODO(toddw): Update the description of manifest files.
 var topicManifest = cmdline.Topic{
 	Name:  "manifest",
 	Short: "Description of manifest files",
diff --git a/doc.go b/doc.go
index 8a5bc8c..9fd3cad 100644
--- a/doc.go
+++ b/doc.go
@@ -14,14 +14,16 @@
 The jiri commands are:
    cl           Manage project changelists
    contributors List project contributors
+   import       Adds imports to .jiri_manifest file
    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
    help         Display help for commands or topics
 
 The jiri additional help topics are:
-   layout      Description of jiri file system layout
+   filesystem  Description of jiri file system layout
    manifest    Description of manifest files
 
 The jiri flags are:
@@ -204,6 +206,52 @@
  -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.  The arguments and flags configure the <import> element that is added to
+the manifest.
+
+Run "jiri help manifest" for details on manifests.
+
+Usage:
+   jiri import [flags] <remote> <manifest>
+
+<remote> specifies the remote repository that contains your manifest project.
+
+<manifest> specifies the manifest file to use from the manifest project.
+
+The jiri import flags are:
+ -mode=append
+   The import mode:
+      append    - Create file if it doesn't exist, or append to existing file.
+      overwrite - Write file regardless of whether it already exists.
+ -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 "-".
+ -path=
+   Path to store the manifest project locally.  Uses "manifest" if unspecified.
+ -protocol=git
+   The version control protocol used by the remote manifest project.
+ -remotebranch=master
+   The branch of the remote manifest project to track.
+ -revision=HEAD
+   The revision of the remote manifest project to reset to during "jiri update".
+ -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 project - Manage the jiri projects
 
 Manage the jiri projects.
@@ -458,6 +506,51 @@
  -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 help - Display help for commands or topics
 
 Help with no args displays the usage of the parent command.
@@ -484,7 +577,7 @@
    Defaults to the terminal width if available.  Override the default by setting
    the CMDLINE_WIDTH environment variable.
 
-Jiri layout - Description of jiri file system layout
+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
diff --git a/import.go b/import.go
new file mode 100644
index 0000000..48c092b
--- /dev/null
+++ b/import.go
@@ -0,0 +1,138 @@
+// 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"
+
+	"v.io/jiri/jiri"
+	"v.io/jiri/project"
+	"v.io/jiri/runutil"
+	"v.io/x/lib/cmdline"
+)
+
+var (
+	// Flags for configuring project attributes.
+	flagImportName, flagImportPath, flagImportProtocol, flagImportRemoteBranch, flagImportRevision, flagImportRoot string
+	// Flags for controlling the behavior of the command.
+	flagImportMode importMode
+	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(&flagImportPath, "path", "", `Path to store the manifest project locally.  Uses "manifest" if unspecified.`)
+	cmdImport.Flags.StringVar(&flagImportProtocol, "protocol", "git", `The version control protocol used by the remote manifest project.`)
+	cmdImport.Flags.StringVar(&flagImportRemoteBranch, "remotebranch", "master", `The branch of the remote manifest project to track.`)
+	cmdImport.Flags.StringVar(&flagImportRevision, "revision", "HEAD", `The revision of the remote manifest project to reset to during "jiri update".`)
+	cmdImport.Flags.StringVar(&flagImportRoot, "root", "", `Root to store the manifest project locally.`)
+
+	cmdImport.Flags.Var(&flagImportMode, "mode", `
+The import mode:
+   append    - Create file if it doesn't exist, or append to existing file.
+   overwrite - Write file regardless of whether it already exists.
+`)
+	cmdImport.Flags.StringVar(&flagImportOut, "out", "", `The output file.  Uses $JIRI_ROOT/.jiri_manifest if unspecified.  Uses stdout if set to "-".`)
+}
+
+type importMode int
+
+const (
+	importAppend importMode = iota
+	importOverwrite
+)
+
+func (m *importMode) Set(s string) error {
+	switch s {
+	case "append":
+		*m = importAppend
+		return nil
+	case "overwrite":
+		*m = importOverwrite
+		return nil
+	}
+	return fmt.Errorf("unknown import mode %q", s)
+}
+
+func (m importMode) String() string {
+	switch m {
+	case importAppend:
+		return "append"
+	case importOverwrite:
+		return "overwrite"
+	}
+	return "UNKNOWN"
+}
+
+func (m importMode) Get() interface{} {
+	return m
+}
+
+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.  The arguments and flags configure the <import> element that is added to
+the manifest.
+
+Run "jiri help manifest" for details on manifests.
+`,
+	ArgsName: "<remote> <manifest>",
+	ArgsLong: `
+<remote> specifies the remote repository that contains your manifest project.
+
+<manifest> specifies the manifest file to use from the manifest project.
+`,
+}
+
+func runImport(jirix *jiri.X, args []string) error {
+	if len(args) != 2 || args[0] == "" || args[1] == "" {
+		return jirix.UsageErrorf("must specify non-empty <remote> and <manifest>")
+	}
+	// Initialize manifest.
+	var manifest *project.Manifest
+	if flagImportMode == importAppend {
+		m, err := project.ManifestFromFile(jirix, jirix.JiriManifestFile())
+		if err != nil && !runutil.IsNotExist(err) {
+			return err
+		}
+		manifest = m
+	}
+	if manifest == nil {
+		manifest = &project.Manifest{}
+	}
+	// Add remote import.
+	manifest.Imports = append(manifest.Imports, project.Import{
+		Manifest: args[1],
+		Root:     flagImportRoot,
+		Project: project.Project{
+			Name:         flagImportName,
+			Path:         flagImportPath,
+			Protocol:     flagImportProtocol,
+			Remote:       args[0],
+			RemoteBranch: flagImportRemoteBranch,
+			Revision:     flagImportRevision,
+		},
+	})
+	// 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/import_test.go b/import_test.go
new file mode 100644
index 0000000..5895ff3
--- /dev/null
+++ b/import_test.go
@@ -0,0 +1,228 @@
+// 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"
+	"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: `must specify non-empty`,
+		},
+		{
+			Args:   []string{"https://github.com/new.git"},
+			Stderr: `must specify non-empty`,
+		},
+		// Default mode = append
+		{
+			Args: []string{"-name=name", "-path=path", "-remotebranch=remotebranch", "-revision=revision", "-root=root", "https://github.com/new.git", "foo"},
+			Want: `<manifest>
+  <imports>
+    <import manifest="foo" root="root" name="name" path="path" remote="https://github.com/new.git" remotebranch="remotebranch" revision="revision"/>
+  </imports>
+</manifest>
+`,
+		},
+		{
+			Args: []string{"https://github.com/new.git", "foo"},
+			Want: `<manifest>
+  <imports>
+    <import manifest="foo" remote="https://github.com/new.git"/>
+  </imports>
+</manifest>
+`,
+		},
+		{
+			Args:     []string{"-out=file", "https://github.com/new.git", "foo"},
+			Filename: `file`,
+			Want: `<manifest>
+  <imports>
+    <import manifest="foo" remote="https://github.com/new.git"/>
+  </imports>
+</manifest>
+`,
+		},
+		{
+			Args: []string{"-out=-", "https://github.com/new.git", "foo"},
+			Stdout: `<manifest>
+  <imports>
+    <import manifest="foo" remote="https://github.com/new.git"/>
+  </imports>
+</manifest>
+`,
+		},
+		{
+			Args: []string{"https://github.com/new.git", "foo"},
+			Exist: `<manifest>
+  <imports>
+    <import manifest="bar" remote="https://github.com/exist.git"/>
+  </imports>
+</manifest>
+`,
+			Want: `<manifest>
+  <imports>
+    <import manifest="bar" remote="https://github.com/exist.git"/>
+    <import manifest="foo" remote="https://github.com/new.git"/>
+  </imports>
+</manifest>
+`,
+		},
+		// Explicit mode = append
+		{
+			Args: []string{"-mode=append", "https://github.com/new.git", "foo"},
+			Want: `<manifest>
+  <imports>
+    <import manifest="foo" remote="https://github.com/new.git"/>
+  </imports>
+</manifest>
+`,
+		},
+		{
+			Args:     []string{"-mode=append", "-out=file", "https://github.com/new.git", "foo"},
+			Filename: `file`,
+			Want: `<manifest>
+  <imports>
+    <import manifest="foo" remote="https://github.com/new.git"/>
+  </imports>
+</manifest>
+`,
+		},
+		{
+			Args: []string{"-mode=append", "-out=-", "https://github.com/new.git", "foo"},
+			Stdout: `<manifest>
+  <imports>
+    <import manifest="foo" remote="https://github.com/new.git"/>
+  </imports>
+</manifest>
+`,
+		},
+		{
+			Args: []string{"-mode=append", "https://github.com/new.git", "foo"},
+			Exist: `<manifest>
+  <imports>
+    <import manifest="bar" remote="https://github.com/exist.git"/>
+  </imports>
+</manifest>
+`,
+			Want: `<manifest>
+  <imports>
+    <import manifest="bar" remote="https://github.com/exist.git"/>
+    <import manifest="foo" remote="https://github.com/new.git"/>
+  </imports>
+</manifest>
+`,
+		},
+		// Explicit mode = overwrite
+		{
+			Args: []string{"-mode=overwrite", "https://github.com/new.git", "foo"},
+			Want: `<manifest>
+  <imports>
+    <import manifest="foo" remote="https://github.com/new.git"/>
+  </imports>
+</manifest>
+`,
+		},
+		{
+			Args:     []string{"-mode=overwrite", "-out=file", "https://github.com/new.git", "foo"},
+			Filename: `file`,
+			Want: `<manifest>
+  <imports>
+    <import manifest="foo" remote="https://github.com/new.git"/>
+  </imports>
+</manifest>
+`,
+		},
+		{
+			Args: []string{"-mode=overwrite", "-out=-", "https://github.com/new.git", "foo"},
+			Stdout: `<manifest>
+  <imports>
+    <import manifest="foo" remote="https://github.com/new.git"/>
+  </imports>
+</manifest>
+`,
+		},
+		{
+			Args: []string{"-mode=overwrite", "https://github.com/new.git", "foo"},
+			Exist: `<manifest>
+  <imports>
+    <import manifest="bar" remote="https://github.com/exist.git"/>
+  </imports>
+</manifest>
+`,
+			Want: `<manifest>
+  <imports>
+    <import manifest="foo" remote="https://github.com/new.git"/>
+  </imports>
+</manifest>
+`,
+		},
+	}
+	opts := gosh.Opts{Errorf: 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()
+	jiriRoot := sh.MakeTempDir()
+	sh.Pushd(jiriRoot)
+	defer sh.Popd()
+	filename := test.Filename
+	if filename == "" {
+		filename = ".jiri_manifest"
+	}
+	// 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.Output()
+	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/jiri/.api b/jiri/.api
index 951333b..0addbef 100644
--- a/jiri/.api
+++ b/jiri/.api
@@ -1,3 +1,4 @@
+pkg jiri, const JiriManifestFile ideal-string
 pkg jiri, const ProjectMetaDir ideal-string
 pkg jiri, const ProjectMetaFile ideal-string
 pkg jiri, const RootEnv ideal-string
@@ -9,7 +10,7 @@
 pkg jiri, func RunnerFunc(func(*X, []string) error) cmdline.Runner
 pkg jiri, method (*X) BinDir() string
 pkg jiri, method (*X) Clone(tool.ContextOpts) *X
-pkg jiri, method (*X) LocalManifestFile() string
+pkg jiri, method (*X) JiriManifestFile() string
 pkg jiri, method (*X) LocalSnapshotDir() string
 pkg jiri, method (*X) ManifestDir() string
 pkg jiri, method (*X) ManifestFile(string) string
@@ -18,6 +19,7 @@
 pkg jiri, method (*X) RootMetaDir() string
 pkg jiri, method (*X) UpdateHistoryDir() string
 pkg jiri, method (*X) UsageErrorf(string, ...interface{}) error
+pkg jiri, method (*X) UsingOldManifests() bool
 pkg jiri, method (RelPath) Abs(*X) string
 pkg jiri, method (RelPath) Join(...string) RelPath
 pkg jiri, method (RelPath) Symbolic() string
diff --git a/jiri/x.go b/jiri/x.go
index c5735f6..685283c 100644
--- a/jiri/x.go
+++ b/jiri/x.go
@@ -19,10 +19,11 @@
 )
 
 const (
-	RootEnv         = "JIRI_ROOT"
-	RootMetaDir     = ".jiri_root"
-	ProjectMetaDir  = ".jiri"
-	ProjectMetaFile = "metadata.v2"
+	RootEnv          = "JIRI_ROOT"
+	RootMetaDir      = ".jiri_root"
+	ProjectMetaDir   = ".jiri"
+	ProjectMetaFile  = "metadata.v2"
+	JiriManifestFile = ".jiri_manifest"
 )
 
 // X holds the execution environment for the jiri tool and related tools.  This
@@ -104,7 +105,7 @@
 	if x.Usage != nil {
 		return x.Usage(format, args...)
 	}
-	return nil
+	return fmt.Errorf(format, args...)
 }
 
 // RootMetaDir returns the path to the root metadata directory.
@@ -112,6 +113,19 @@
 	return filepath.Join(x.Root, RootMetaDir)
 }
 
+// JiriManifestFile returns the path to the .jiri_manifest file.
+func (x *X) JiriManifestFile() string {
+	return filepath.Join(x.Root, JiriManifestFile)
+}
+
+// UsingOldManifests returns true iff the JIRI_ROOT/.jiri_manifest file does not
+// exist.  This is the one signal used to decide whether to use the old manifest
+// logic (checking .local_manifest and the -manifest flag), or the new logic.
+func (x *X) UsingOldManifests() bool {
+	_, err := os.Stat(x.JiriManifestFile())
+	return os.IsNotExist(err)
+}
+
 // BinDir returns the path to the bin directory.
 func (x *X) BinDir() string {
 	return filepath.Join(x.RootMetaDir(), "bin")
@@ -122,11 +136,6 @@
 	return filepath.Join(x.RootMetaDir(), "update_history")
 }
 
-// LocalManifestFile returns the path to the local manifest file.
-func (x *X) LocalManifestFile() string {
-	return filepath.Join(x.Root, ".local_manifest")
-}
-
 // LocalSnapshotDir returns the path to the local snapshot directory.
 func (x *X) LocalSnapshotDir() string {
 	return filepath.Join(x.Root, ".snapshot")
@@ -150,13 +159,26 @@
 // ResolveManifestPath resolves the given manifest name to an absolute path in
 // the local filesystem.
 func (x *X) ResolveManifestPath(name string) (string, error) {
+	if x.UsingOldManifests() {
+		return x.resolveManifestPathDeprecated(name)
+	}
+	if name != "" {
+		return "", fmt.Errorf("-manifest flag isn't supported with .jiri_manifest")
+	}
+	return x.JiriManifestFile(), nil
+}
+
+// Deprecated logic, only run if JIRI_ROOT/.jiri_manifest doesn't exist.
+//
+// TODO(toddw): Remove this logic when the transition to .jiri_manifest is done.
+func (x *X) resolveManifestPathDeprecated(name string) (string, error) {
 	if name != "" {
 		if filepath.IsAbs(name) {
 			return name, nil
 		}
 		return x.ManifestFile(name), nil
 	}
-	path := x.LocalManifestFile()
+	path := filepath.Join(x.Root, ".local_manifest")
 	switch _, err := os.Stat(path); {
 	case err == nil:
 		return path, nil
diff --git a/jiritest/fake.go b/jiritest/fake.go
index 8195aac..4987b15 100644
--- a/jiritest/fake.go
+++ b/jiritest/fake.go
@@ -5,7 +5,6 @@
 package jiritest
 
 import (
-	"encoding/xml"
 	"fmt"
 	"os"
 	"path/filepath"
@@ -212,25 +211,13 @@
 // ReadLocalManifest read a manifest from the local manifest project.
 func (fake FakeJiriRoot) ReadLocalManifest() (*project.Manifest, error) {
 	path := filepath.Join(fake.X.Root, manifestProject, manifestVersion, getManifest(fake.X))
-	return fake.readManifest(path)
+	return project.ManifestFromFile(fake.X, path)
 }
 
 // ReadRemoteManifest read a manifest from the remote manifest project.
 func (fake FakeJiriRoot) ReadRemoteManifest() (*project.Manifest, error) {
 	path := filepath.Join(fake.remote, manifestProject, manifestVersion, getManifest(fake.X))
-	return fake.readManifest(path)
-}
-
-func (fake FakeJiriRoot) readManifest(path string) (*project.Manifest, error) {
-	bytes, err := fake.X.NewSeq().ReadFile(path)
-	if err != nil {
-		return nil, err
-	}
-	var manifest project.Manifest
-	if err := xml.Unmarshal(bytes, &manifest); err != nil {
-		return nil, fmt.Errorf("Unmarshal(%v) failed: %v", string(bytes), err)
-	}
-	return &manifest, nil
+	return project.ManifestFromFile(fake.X, path)
 }
 
 // UpdateUniverse synchronizes the content of the Vanadium fake based
@@ -264,11 +251,7 @@
 }
 
 func (fake FakeJiriRoot) writeManifest(manifest *project.Manifest, dir, path string) error {
-	bytes, err := xml.Marshal(manifest)
-	if err != nil {
-		return fmt.Errorf("Marshal(%v) failed: %v", manifest, err)
-	}
-	if err := fake.X.NewSeq().WriteFile(path, bytes, os.FileMode(0600)).Done(); err != nil {
+	if err := manifest.ToFile(fake.X, path); err != nil {
 		return err
 	}
 	if err := fake.X.Git(tool.RootDirOpt(dir)).Add(path); err != nil {
diff --git a/project/.api b/project/.api
index 82b6d30..5e8cc9a 100644
--- a/project/.api
+++ b/project/.api
@@ -12,12 +12,21 @@
 pkg project, func GitHost(*jiri.X) (string, error)
 pkg project, func InstallTools(*jiri.X, string) error
 pkg project, func LocalProjects(*jiri.X, ScanMode) (Projects, error)
+pkg project, func MakeProjectKey(string, string) ProjectKey
+pkg project, func ManifestFromBytes([]byte) (*Manifest, error)
+pkg project, func ManifestFromFile(*jiri.X, string) (*Manifest, error)
 pkg project, func ParseNames(*jiri.X, []string, map[string]struct{}) (Projects, error)
 pkg project, func PollProjects(*jiri.X, map[string]struct{}) (Update, error)
+pkg project, func ProjectFromFile(*jiri.X, string) (*Project, error)
 pkg project, func ReadManifest(*jiri.X) (Projects, Tools, error)
 pkg project, func TransitionBinDir(*jiri.X) error
 pkg project, func UpdateUniverse(*jiri.X, bool) error
+pkg project, method (*Manifest) ToBytes() ([]byte, error)
+pkg project, method (*Manifest) ToFile(*jiri.X, string) error
+pkg project, method (Import) Key() ProjectKey
+pkg project, method (Import) ToFile(*jiri.X, string) error
 pkg project, method (Project) Key() ProjectKey
+pkg project, method (Project) ToFile(*jiri.X, string) error
 pkg project, method (ProjectKeys) Len() int
 pkg project, method (ProjectKeys) Less(int, int) bool
 pkg project, method (ProjectKeys) Swap(int, int)
@@ -31,28 +40,37 @@
 pkg project, type CL struct, Author string
 pkg project, type CL struct, Description string
 pkg project, type CL struct, Email string
+pkg project, type FileImport struct
+pkg project, type FileImport struct, File string
+pkg project, type FileImport struct, XMLName struct{}
 pkg project, type GitHook struct
 pkg project, type GitHook struct, Name string
 pkg project, type GitHook struct, Path string
+pkg project, type GitHook struct, XMLName struct{}
 pkg project, type Hook struct
 pkg project, type Hook struct, Args []HookArg
-pkg project, type Hook struct, Exclude bool
 pkg project, type Hook struct, Interpreter string
 pkg project, type Hook struct, Name string
 pkg project, type Hook struct, Path string
 pkg project, type Hook struct, Project string
+pkg project, type Hook struct, XMLName struct{}
 pkg project, type HookArg struct
 pkg project, type HookArg struct, Arg string
+pkg project, type HookArg struct, XMLName struct{}
 pkg project, type Hooks map[string]Hook
 pkg project, type Host struct
 pkg project, type Host struct, GitHooks []GitHook
 pkg project, type Host struct, Location string
 pkg project, type Host struct, Name string
+pkg project, type Host struct, XMLName struct{}
 pkg project, type Hosts map[string]Host
 pkg project, type Import struct
-pkg project, type Import struct, Name string
-pkg project, type Imports map[string]Import
+pkg project, type Import struct, Manifest string
+pkg project, type Import struct, Root string
+pkg project, type Import struct, XMLName struct{}
+pkg project, type Import struct, embedded Project
 pkg project, type Manifest struct
+pkg project, type Manifest struct, FileImports []FileImport
 pkg project, type Manifest struct, Hooks []Hook
 pkg project, type Manifest struct, Hosts []Host
 pkg project, type Manifest struct, Imports []Import
@@ -61,13 +79,13 @@
 pkg project, type Manifest struct, Tools []Tool
 pkg project, type Manifest struct, XMLName struct{}
 pkg project, type Project struct
-pkg project, type Project struct, Exclude bool
 pkg project, type Project struct, Name string
 pkg project, type Project struct, Path string
 pkg project, type Project struct, Protocol string
 pkg project, type Project struct, Remote string
 pkg project, type Project struct, RemoteBranch string
 pkg project, type Project struct, Revision string
+pkg project, type Project struct, XMLName struct{}
 pkg project, type ProjectKey string
 pkg project, type ProjectKeys []ProjectKey
 pkg project, type ProjectState struct
@@ -80,10 +98,10 @@
 pkg project, type ScanMode bool
 pkg project, type Tool struct
 pkg project, type Tool struct, Data string
-pkg project, type Tool struct, Exclude bool
 pkg project, type Tool struct, Name string
 pkg project, type Tool struct, Package string
 pkg project, type Tool struct, Project string
+pkg project, type Tool struct, XMLName struct{}
 pkg project, type Tools map[string]Tool
 pkg project, type UnsupportedProtocolErr string
 pkg project, type Update map[string][]CL
diff --git a/project/paths.go b/project/paths.go
index ce0e523..2c0a2d9 100644
--- a/project/paths.go
+++ b/project/paths.go
@@ -18,7 +18,7 @@
 // breaks.  We should revisit the whole data directory thing, and in particular
 // see if we can get rid of tools having to know their own names.
 func DataDirPath(jirix *jiri.X, toolName string) (string, error) {
-	_, projects, tools, _, err := readManifest(jirix, false)
+	_, projects, tools, _, err := readManifest(jirix)
 	if err != nil {
 		return "", err
 	}
@@ -42,7 +42,7 @@
 }
 
 func getHost(jirix *jiri.X, name string) (string, error) {
-	hosts, _, _, _, err := readManifest(jirix, false)
+	hosts, _, _, _, err := readManifest(jirix)
 	if err != nil {
 		return "", err
 	}
@@ -62,19 +62,3 @@
 func GitHost(jirix *jiri.X) (string, error) {
 	return getHost(jirix, "git")
 }
-
-func toAbs(jirix *jiri.X, path string) string {
-	if filepath.IsAbs(path) {
-		return path
-	}
-	return filepath.Join(jirix.Root, path)
-}
-
-// toRel returns the given path relative to JIRI_ROOT, if it is not already a
-// relative path.
-func toRel(jirix *jiri.X, path string) (string, error) {
-	if !filepath.IsAbs(path) {
-		return path, nil
-	}
-	return filepath.Rel(jirix.Root, path)
-}
diff --git a/project/project.go b/project/project.go
index 1e2c13e..c01f1e4 100644
--- a/project/project.go
+++ b/project/project.go
@@ -40,13 +40,193 @@
 
 // Manifest represents a setting used for updating the universe.
 type Manifest struct {
-	Hooks    []Hook    `xml:"hooks>hook"`
-	Hosts    []Host    `xml:"hosts>host"`
-	Imports  []Import  `xml:"imports>import"`
-	Label    string    `xml:"label,attr"`
-	Projects []Project `xml:"projects>project"`
-	Tools    []Tool    `xml:"tools>tool"`
-	XMLName  struct{}  `xml:"manifest"`
+	Hooks       []Hook       `xml:"hooks>hook"`
+	Hosts       []Host       `xml:"hosts>host"`
+	Imports     []Import     `xml:"imports>import"`
+	FileImports []FileImport `xml:"imports>fileimport"`
+	Label       string       `xml:"label,attr,omitempty"`
+	Projects    []Project    `xml:"projects>project"`
+	Tools       []Tool       `xml:"tools>tool"`
+	XMLName     struct{}     `xml:"manifest"`
+}
+
+// ManifestFromBytes returns a manifest parsed from data, with defaults filled
+// in.
+func ManifestFromBytes(data []byte) (*Manifest, error) {
+	m := new(Manifest)
+	if err := xml.Unmarshal(data, m); err != nil {
+		return nil, err
+	}
+	if err := m.fillDefaults(); err != nil {
+		return nil, err
+	}
+	return m, nil
+}
+
+// ManifestFromFile returns a manifest parsed from the contents of filename,
+// with defaults filled in.
+func ManifestFromFile(jirix *jiri.X, filename string) (*Manifest, error) {
+	data, err := jirix.NewSeq().ReadFile(filename)
+	if err != nil {
+		return nil, err
+	}
+	m, err := ManifestFromBytes(data)
+	if err != nil {
+		return nil, fmt.Errorf("invalid manifest %s: %v", filename, err)
+	}
+	return m, nil
+}
+
+var (
+	newlineBytes       = []byte("\n")
+	emptyHooksBytes    = []byte("\n  <hooks></hooks>\n")
+	emptyGitHooksBytes = []byte("\n      <githooks></githooks>\n")
+	emptyHostsBytes    = []byte("\n  <hosts></hosts>\n")
+	emptyImportsBytes  = []byte("\n  <imports></imports>\n")
+	emptyProjectsBytes = []byte("\n  <projects></projects>\n")
+	emptyToolsBytes    = []byte("\n  <tools></tools>\n")
+
+	endElemBytes       = []byte("/>\n")
+	endHookBytes       = []byte("></hook>\n")
+	endGitHookBytes    = []byte("></githook>\n")
+	endHostBytes       = []byte("></host>\n")
+	endImportBytes     = []byte("></import>\n")
+	endFileImportBytes = []byte("></fileimport>\n")
+	endProjectBytes    = []byte("></project>\n")
+	endToolBytes       = []byte("></tool>\n")
+
+	endImportSoloBytes  = []byte("></import>")
+	endProjectSoloBytes = []byte("></project>")
+	endElemSoloBytes    = []byte("/>")
+)
+
+// deepCopy returns a deep copy of Manifest.
+func (m *Manifest) deepCopy() *Manifest {
+	x := new(Manifest)
+	x.Label = m.Label
+	// First make copies of all slices.
+	x.Hooks = append([]Hook(nil), m.Hooks...)
+	x.Hosts = append([]Host(nil), m.Hosts...)
+	x.Imports = append([]Import(nil), m.Imports...)
+	x.FileImports = append([]FileImport(nil), m.FileImports...)
+	x.Projects = append([]Project(nil), m.Projects...)
+	x.Tools = append([]Tool(nil), m.Tools...)
+	// Now make copies of sub-slices.
+	for index, hook := range x.Hooks {
+		x.Hooks[index].Args = append([]HookArg(nil), hook.Args...)
+	}
+	for index, host := range x.Hosts {
+		x.Hosts[index].GitHooks = append([]GitHook(nil), host.GitHooks...)
+	}
+	return x
+}
+
+// ToBytes returns m as serialized bytes, with defaults unfilled.
+func (m *Manifest) ToBytes() ([]byte, error) {
+	m = m.deepCopy() // avoid changing manifest when unfilling defaults.
+	if err := m.unfillDefaults(); err != nil {
+		return nil, err
+	}
+	data, err := xml.MarshalIndent(m, "", "  ")
+	if err != nil {
+		return nil, fmt.Errorf("manifest xml.Marshal failed: %v", err)
+	}
+	// It's hard (impossible?) to get xml.Marshal to elide some of the empty
+	// elements, or produce short empty elements, so we post-process the data.
+	data = bytes.Replace(data, emptyHooksBytes, newlineBytes, -1)
+	data = bytes.Replace(data, emptyGitHooksBytes, newlineBytes, -1)
+	data = bytes.Replace(data, emptyHostsBytes, newlineBytes, -1)
+	data = bytes.Replace(data, emptyImportsBytes, newlineBytes, -1)
+	data = bytes.Replace(data, emptyProjectsBytes, newlineBytes, -1)
+	data = bytes.Replace(data, emptyToolsBytes, newlineBytes, -1)
+	data = bytes.Replace(data, endHookBytes, endElemBytes, -1)
+	data = bytes.Replace(data, endGitHookBytes, endElemBytes, -1)
+	data = bytes.Replace(data, endHostBytes, endElemBytes, -1)
+	data = bytes.Replace(data, endImportBytes, endElemBytes, -1)
+	data = bytes.Replace(data, endFileImportBytes, endElemBytes, -1)
+	data = bytes.Replace(data, endProjectBytes, endElemBytes, -1)
+	data = bytes.Replace(data, endToolBytes, endElemBytes, -1)
+	if !bytes.HasSuffix(data, newlineBytes) {
+		data = append(data, '\n')
+	}
+	return data, nil
+}
+
+func safeWriteFile(jirix *jiri.X, filename string, data []byte) error {
+	tmp := filename + ".tmp"
+	return jirix.NewSeq().
+		MkdirAll(filepath.Dir(filename), 0755).
+		WriteFile(tmp, data, 0644).
+		Rename(tmp, filename).
+		Done()
+}
+
+// ToFile writes the manifest m to a file with the given filename, with defaults
+// unfilled.
+func (m *Manifest) ToFile(jirix *jiri.X, filename string) error {
+	data, err := m.ToBytes()
+	if err != nil {
+		return err
+	}
+	return safeWriteFile(jirix, filename, data)
+}
+
+func (m *Manifest) fillDefaults() error {
+	for index := range m.Hosts {
+		if err := m.Hosts[index].validate(); err != nil {
+			return err
+		}
+	}
+	for index := range m.Imports {
+		if err := m.Imports[index].fillDefaults(); err != nil {
+			return err
+		}
+	}
+	for index := range m.FileImports {
+		if err := m.FileImports[index].validate(); err != nil {
+			return err
+		}
+	}
+	for index := range m.Projects {
+		if err := m.Projects[index].fillDefaults(); err != nil {
+			return err
+		}
+	}
+	for index := range m.Tools {
+		if err := m.Tools[index].fillDefaults(); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+func (m *Manifest) unfillDefaults() error {
+	for index := range m.Hosts {
+		if err := m.Hosts[index].validate(); err != nil {
+			return err
+		}
+	}
+	for index := range m.Imports {
+		if err := m.Imports[index].unfillDefaults(); err != nil {
+			return err
+		}
+	}
+	for index := range m.FileImports {
+		if err := m.FileImports[index].validate(); err != nil {
+			return err
+		}
+	}
+	for index := range m.Projects {
+		if err := m.Projects[index].unfillDefaults(); err != nil {
+			return err
+		}
+	}
+	for index := range m.Tools {
+		if err := m.Tools[index].unfillDefaults(); err != nil {
+			return err
+		}
+	}
+	return nil
 }
 
 // Hooks maps hook names to their detailed description.
@@ -54,23 +234,23 @@
 
 // Hook represents a post-update project hook.
 type Hook struct {
-	// Exclude is flag used to exclude previously included hooks.
-	Exclude bool `xml:"exclude,attr"`
 	// Name is the hook name.
-	Name string `xml:"name,attr"`
+	Name string `xml:"name,attr,omitempty"`
 	// Project is the name of the project the hook is associated with.
-	Project string `xml:"project,attr"`
+	Project string `xml:"project,attr,omitempty"`
 	// Path is the path of the hook relative to its project's root.
-	Path string `xml:"path,attr"`
+	Path string `xml:"path,attr,omitempty"`
 	// Interpreter is an optional program used to interpret the hook (i.e. python). Unlike Path,
 	// Interpreter is relative to the environment's PATH and not the project's root.
-	Interpreter string `xml:"interpreter,attr"`
+	Interpreter string `xml:"interpreter,attr,omitempty"`
 	// Arguments for the hook.
-	Args []HookArg `xml:"arg"`
+	Args    []HookArg `xml:"arg,omitempty"`
+	XMLName struct{}  `xml:"hook"`
 }
 
 type HookArg struct {
-	Arg string `xml:",chardata"`
+	Arg     string   `xml:",chardata,omitempty"`
+	XMLName struct{} `xml:"arg"`
 }
 
 // Hosts map host name to their detailed description.
@@ -79,36 +259,145 @@
 // Host represents the locations of git and gerrit repository hosts.
 type Host struct {
 	// Name is the host name.
-	Name string `xml:"name,attr"`
+	Name string `xml:"name,attr,omitempty"`
 	// Location is the url of the host.
-	Location string `xml:"location,attr"`
+	Location string `xml:"location,attr,omitempty"`
 	// Git hooks to apply to repos from this host.
 	GitHooks []GitHook `xml:"githooks>githook"`
+	XMLName  struct{}  `xml:"host"`
+}
+
+func (h *Host) validate() error {
+	if len(h.GitHooks) > 0 && h.Name != "git" {
+		return fmt.Errorf("bad host: githook provided for non-git host: %+v", *h)
+	}
+	return nil
 }
 
 // GitHook represents the name and source of git hooks.
 type GitHook struct {
 	// The hook name, as required by git (e.g. commit-msg, pre-rebase, etc.)
-	Name string `xml:"name,attr"`
+	Name string `xml:"name,attr,omitempty"`
 	// The filename of the hook implementation.  When editing the manifest,
-	// specify this path as relative to the manifest dir.  In loadManifest,
-	// this gets resolved to the absolute path.
-	Path string `xml:"path,attr"`
+	// specify this path as relative to the manifest dir.
+	Path    string   `xml:"path,attr,omitempty"`
+	XMLName struct{} `xml:"githook"`
 }
 
-// Imports maps manifest import names to their detailed description.
-type Imports map[string]Import
-
-// Import represnts a manifest import.
+// Import represents a remote manifest import.
 type Import struct {
-	// Name is the name under which the manifest can be found the
-	// manifest repository.
-	Name string `xml:"name,attr"`
+	// Manifest file to use from the remote manifest project.
+	Manifest string `xml:"manifest,attr,omitempty"`
+	// Root path, prepended to the manifest project path, as well as all projects
+	// specified in the manifest file.
+	Root string `xml:"root,attr,omitempty"`
+	// Project description of the manifest repository.
+	Project
+	XMLName struct{} `xml:"import"`
+}
+
+// ToFile writes the import i to a file with the given filename, with defaults
+// unfilled.
+func (i Import) ToFile(jirix *jiri.X, filename string) error {
+	if err := i.unfillDefaults(); err != nil {
+		return err
+	}
+	data, err := xml.Marshal(i)
+	if err != nil {
+		return fmt.Errorf("import xml.Marshal failed: %v", err)
+	}
+	// Same logic as Manifest.ToBytes, to make the output more compact.
+	data = bytes.Replace(data, endImportSoloBytes, endElemSoloBytes, -1)
+	return safeWriteFile(jirix, filename, data)
+}
+
+func (i *Import) fillDefaults() error {
+	if i.Remote != "" {
+		if i.Path == "" {
+			i.Path = "manifest"
+		}
+		if err := i.Project.fillDefaults(); err != nil {
+			return err
+		}
+	}
+	return i.validate()
+}
+
+func (i *Import) unfillDefaults() error {
+	if i.Remote != "" {
+		if i.Path == "manifest" {
+			i.Path = ""
+		}
+		if err := i.Project.unfillDefaults(); err != nil {
+			return err
+		}
+	}
+	return i.validate()
+}
+
+func (i *Import) validate() error {
+	// After our transition is done, the "import" element will always denote
+	// remote imports, and the "remote" and "manifest" attributes will be
+	// required.  During the transition we allow old-style local imports, which
+	// only set the "name" attribute.
+	//
+	// This is a bit tricky, since the "name" attribute is allowed in both old and
+	// new styles, but have different semantics.  We distinguish between old and
+	// new styles based on the existence of the "remote" attribute.
+	oldStyle := *i
+	oldStyle.Name = ""
+	switch {
+	case i.Name != "" && oldStyle == Import{}:
+		// Only "name" is set, this is the old-style.
+	case i.Remote != "" && i.Manifest != "":
+		// At least "remote" and "manifest" are set, this is the new-style.
+	default:
+		return fmt.Errorf("bad import: neither old style (only name is set) or new style (at least remote and manifest are set): %+v", *i)
+	}
+	return nil
+}
+
+// remoteKey returns a key based on the remote and manifest, used for
+// cycle-detection.  It's only valid for new-style remote imports; it's empty
+// for the old-style local imports.
+func (i *Import) remoteKey() string {
+	if i.Remote == "" {
+		return ""
+	}
+	// We don't join the remote and manifest with a slash, since that might not be
+	// unique.  E.g.
+	//   remote:   https://foo.com/a/b    remote:   https://foo.com/a
+	//   manifest: c                      manifest: b/c
+	// In both cases, the key would be https://foo.com/a/b/c.
+	return i.Remote + " + " + i.Manifest
+}
+
+// FileImport represents a file-based import.
+type FileImport struct {
+	// Manifest file to import from.
+	File    string   `xml:"file,attr,omitempty"`
+	XMLName struct{} `xml:"fileimport"`
+}
+
+func (i *FileImport) validate() error {
+	if i.File == "" {
+		return fmt.Errorf("bad fileimport: must specify file: %+v", *i)
+	}
+	return nil
 }
 
 // ProjectKey is a unique string for a project.
 type ProjectKey string
 
+// MakeProjectKey returns the project key, given the project name and remote.
+func MakeProjectKey(name, remote string) ProjectKey {
+	return ProjectKey(name + projectKeySeparator + remote)
+}
+
+// projectKeySeparator is a reserved string used in ProjectKeys.  It cannot
+// occur in Project names.
+const projectKeySeparator = "="
+
 // ProjectKeys is a slice of ProjectKeys implementing the Sort interface.
 type ProjectKeys []ProjectKey
 
@@ -118,64 +407,149 @@
 
 // Project represents a jiri project.
 type Project struct {
-	// Exclude is flag used to exclude previously included projects.
-	Exclude bool `xml:"exclude,attr"`
 	// Name is the project name.
-	Name string `xml:"name,attr"`
+	Name string `xml:"name,attr,omitempty"`
 	// Path is the path used to store the project locally. Project
 	// manifest uses paths that are relative to the $JIRI_ROOT
 	// environment variable. When a manifest is parsed (e.g. in
 	// RemoteProjects), the program logic converts the relative
 	// paths to an absolute paths, using the current value of the
 	// $JIRI_ROOT environment variable as a prefix.
-	Path string `xml:"path,attr"`
+	Path string `xml:"path,attr,omitempty"`
 	// Protocol is the version control protocol used by the
 	// project. If not set, "git" is used as the default.
-	Protocol string `xml:"protocol,attr"`
+	Protocol string `xml:"protocol,attr,omitempty"`
 	// Remote is the project remote.
-	Remote string `xml:"remote,attr"`
+	Remote string `xml:"remote,attr,omitempty"`
 	// RemoteBranch is the name of the remote branch to track.  It doesn't affect
 	// the name of the local branch that jiri maintains, which is always "master".
-	RemoteBranch string `xml:"remotebranch,attr"`
+	RemoteBranch string `xml:"remotebranch,attr,omitempty"`
 	// Revision is the revision the project should be advanced to
 	// during "jiri update". If not set, "HEAD" is used as the
 	// default.
-	Revision string `xml:"revision,attr"`
+	Revision string   `xml:"revision,attr,omitempty"`
+	XMLName  struct{} `xml:"project"`
 }
 
-// projectKeySeparator is a reserved string used in ProjectKeys.  It cannot
-// occur in Project names or remotes.
-const projectKeySeparator = "="
+var (
+	startUpperProjectBytes = []byte("<Project")
+	startLowerProjectBytes = []byte("<project")
+	endUpperProjectBytes   = []byte("</Project>")
+	endLowerProjectBytes   = []byte("</project>")
+)
+
+// ProjectFromFile returns a project parsed from the contents of filename,
+// with defaults filled in.
+func ProjectFromFile(jirix *jiri.X, filename string) (*Project, error) {
+	data, err := jirix.NewSeq().ReadFile(filename)
+	if err != nil {
+		return nil, err
+	}
+
+	// Previous versions of the jiri tool had a bug where the project start and
+	// end elements were in upper-case, since the XMLName field was missing.  That
+	// bug is now fixed, but the xml.Unmarshal call is case-sensitive, and will
+	// fail if it sees the upper-case version.  This hack rewrites the elements to
+	// the lower-case version.
+	//
+	// TODO(toddw): Remove when the transition to new manifests is complete.
+	data = bytes.Replace(data, startUpperProjectBytes, startLowerProjectBytes, -1)
+	data = bytes.Replace(data, endUpperProjectBytes, endLowerProjectBytes, -1)
+
+	p := new(Project)
+	if err := xml.Unmarshal(data, p); err != nil {
+		return nil, err
+	}
+	if err := p.fillDefaults(); err != nil {
+		return nil, err
+	}
+	return p, nil
+}
+
+// ToFile writes the project p to a file with the given filename, with defaults
+// unfilled.
+func (p Project) ToFile(jirix *jiri.X, filename string) error {
+	if err := p.unfillDefaults(); err != nil {
+		return err
+	}
+	data, err := xml.Marshal(p)
+	if err != nil {
+		return fmt.Errorf("project xml.Marshal failed: %v", err)
+	}
+	// Same logic as Manifest.ToBytes, to make the output more compact.
+	data = bytes.Replace(data, endProjectSoloBytes, endElemSoloBytes, -1)
+	return safeWriteFile(jirix, filename, data)
+}
 
 // Key returns a unique ProjectKey for the project.
 func (p Project) Key() ProjectKey {
-	return ProjectKey(p.Name + projectKeySeparator + p.Remote)
+	return MakeProjectKey(p.Name, p.Remote)
+}
+
+func (p *Project) fillDefaults() error {
+	if p.Protocol == "" {
+		p.Protocol = "git"
+	}
+	if p.RemoteBranch == "" {
+		p.RemoteBranch = "master"
+	}
+	if p.Revision == "" {
+		p.Revision = "HEAD"
+	}
+	return p.validate()
+}
+
+func (p *Project) unfillDefaults() error {
+	if p.Protocol == "git" {
+		p.Protocol = ""
+	}
+	if p.RemoteBranch == "master" {
+		p.RemoteBranch = ""
+	}
+	if p.Revision == "HEAD" {
+		p.Revision = ""
+	}
+	return p.validate()
+}
+
+func (p *Project) validate() error {
+	if strings.Contains(p.Name, projectKeySeparator) {
+		return fmt.Errorf("bad project: name cannot contain %q: %+v", projectKeySeparator, *p)
+	}
+	if p.Protocol != "" && p.Protocol != "git" {
+		return fmt.Errorf("bad project: only git protocol is supported: %+v", *p)
+	}
+	return nil
 }
 
 // Projects maps ProjectKeys to Projects.
 type Projects map[ProjectKey]Project
 
 // Find returns all projects in Projects with the given key or name.
-func (ps Projects) Find(name string) Projects {
+func (ps Projects) Find(keyOrName string) Projects {
 	projects := Projects{}
-	for _, p := range ps {
-		if name == p.Name {
-			projects[p.Key()] = p
+	if p, ok := ps[ProjectKey(keyOrName)]; ok {
+		projects[ProjectKey(keyOrName)] = p
+	} else {
+		for key, p := range ps {
+			if keyOrName == p.Name {
+				projects[key] = p
+			}
 		}
 	}
 	return projects
 }
 
-// FindUnique returns the project in Projects with the given key or
-// name, and returns an error if none or multiple matching projects are found.
-func (ps Projects) FindUnique(name string) (Project, error) {
+// FindUnique returns the project in Projects with the given key or name, and
+// returns an error if none or multiple matching projects are found.
+func (ps Projects) FindUnique(keyOrName string) (Project, error) {
 	var p Project
-	projects := ps.Find(name)
+	projects := ps.Find(keyOrName)
 	if len(projects) == 0 {
-		return p, fmt.Errorf("no projects found with name %q", name)
+		return p, fmt.Errorf("no projects found with key or name %q", keyOrName)
 	}
 	if len(projects) > 1 {
-		return p, fmt.Errorf("multiple projects found with name %q", name)
+		return p, fmt.Errorf("multiple projects found with name %q", keyOrName)
 	}
 	// Return the only project in projects.
 	for _, project := range projects {
@@ -189,22 +563,40 @@
 
 // Tool represents a jiri tool.
 type Tool struct {
-	// Exclude is flag used to exclude previously included projects.
-	Exclude bool `xml:"exclude,attr"`
 	// Data is a relative path to a directory for storing tool data
 	// (e.g. tool configuration files). The purpose of this field is to
 	// decouple the configuration of the data directory from the tool
 	// itself so that the location of the data directory can change
 	// without the need to change the tool.
-	Data string `xml:"data,attr"`
+	Data string `xml:"data,attr,omitempty"`
 	// Name is the name of the tool binary.
-	Name string `xml:"name,attr"`
+	Name string `xml:"name,attr,omitempty"`
 	// Package is the package path of the tool.
-	Package string `xml:"package,attr"`
+	Package string `xml:"package,attr,omitempty"`
 	// Project identifies the project that contains the tool. If not
 	// set, "https://vanadium.googlesource.com/<JiriProject>" is
 	// used as the default.
-	Project string `xml:"project,attr"`
+	Project string   `xml:"project,attr,omitempty"`
+	XMLName struct{} `xml:"tool"`
+}
+
+func (t *Tool) fillDefaults() error {
+	if t.Data == "" {
+		t.Data = "data"
+	}
+	if t.Project == "" {
+		t.Project = "https://vanadium.googlesource.com/" + JiriProject
+	}
+	return nil
+}
+
+func (t *Tool) unfillDefaults() error {
+	if t.Data == "data" {
+		t.Data = ""
+	}
+	// Don't unfill the jiri project setting, since that's not meant to be
+	// optional.
+	return nil
 }
 
 // ScanMode determines whether LocalProjects should scan the local filesystem
@@ -242,7 +634,7 @@
 		return err
 	}
 	for _, project := range localProjects {
-		relPath, err := toRel(jirix, project.Path)
+		relPath, err := filepath.Rel(jirix.Root, project.Path)
 		if err != nil {
 			return err
 		}
@@ -252,7 +644,7 @@
 
 	// Add all hosts, tools, and hooks from the current manifest to the
 	// snapshot manifest.
-	hosts, _, tools, hooks, err := readManifest(jirix, true)
+	hosts, _, tools, hooks, err := readManifest(jirix)
 	if err != nil {
 		return err
 	}
@@ -265,49 +657,29 @@
 	for _, hook := range hooks {
 		manifest.Hooks = append(manifest.Hooks, hook)
 	}
-
-	s := jirix.NewSeq()
-	data, err := xml.MarshalIndent(manifest, "", "  ")
-	if err != nil {
-		return fmt.Errorf("MarshalIndent(%v) failed: %v", manifest, err)
-	}
-	return s.MkdirAll(filepath.Dir(path), os.FileMode(0755)).
-		WriteFile(path, data, os.FileMode(0644)).Done()
+	return manifest.ToFile(jirix, path)
 }
 
-const currentManifestFileName = ".current_manifest"
-
 // CurrentManifest returns a manifest that identifies the result of
 // the most recent "jiri update" invocation.
 func CurrentManifest(jirix *jiri.X) (*Manifest, error) {
-	currentManifestPath := toAbs(jirix, currentManifestFileName)
-	bytes, err := jirix.NewSeq().ReadFile(currentManifestPath)
-	if err != nil {
-		if runutil.IsNotExist(err) {
-			fmt.Fprintf(jirix.Stderr(), `WARNING: Could not find %s.
+	filename := filepath.Join(jirix.Root, ".current_manifest")
+	m, err := ManifestFromFile(jirix, filename)
+	if runutil.IsNotExist(err) {
+		fmt.Fprintf(jirix.Stderr(), `WARNING: Could not find %s.
 The contents of this file are stored as metadata in binaries the jiri
 tool builds. To fix this problem, please run "jiri update".
-`, currentManifestPath)
-			return &Manifest{}, nil
-		}
-		return nil, err
+`, filename)
+		return &Manifest{}, nil
 	}
-	var m Manifest
-	if err := xml.Unmarshal(bytes, &m); err != nil {
-		return nil, fmt.Errorf("Unmarshal(%v) failed: %v", string(bytes), err)
-	}
-	return &m, nil
+	return m, err
 }
 
 // writeCurrentManifest writes the given manifest to a file that
 // stores the result of the most recent "jiri update" invocation.
 func writeCurrentManifest(jirix *jiri.X, manifest *Manifest) error {
-	currentManifestPath := toAbs(jirix, currentManifestFileName)
-	bytes, err := xml.MarshalIndent(manifest, "", "  ")
-	if err != nil {
-		return fmt.Errorf("MarshalIndent(%v) failed: %v", manifest, err)
-	}
-	return jirix.NewSeq().WriteFile(currentManifestPath, bytes, os.FileMode(0644)).Done()
+	filename := filepath.Join(jirix.Root, ".current_manifest")
+	return manifest.ToFile(jirix, filename)
 }
 
 // CurrentProjectKey gets the key of the current project from the current
@@ -318,18 +690,12 @@
 	if err != nil {
 		return "", nil
 	}
-	s := jirix.NewSeq()
 	metadataDir := filepath.Join(topLevel, jiri.ProjectMetaDir)
-	if _, err := s.Stat(metadataDir); err == nil {
-		metadataFile := filepath.Join(metadataDir, jiri.ProjectMetaFile)
-		bytes, err := s.ReadFile(metadataFile)
+	if _, err := jirix.NewSeq().Stat(metadataDir); err == nil {
+		project, err := ProjectFromFile(jirix, filepath.Join(metadataDir, jiri.ProjectMetaFile))
 		if err != nil {
 			return "", err
 		}
-		var project Project
-		if err := xml.Unmarshal(bytes, &project); err != nil {
-			return "", fmt.Errorf("Unmarshal() failed: %v", err)
-		}
 		return project.Key(), nil
 	}
 	return "", nil
@@ -390,9 +756,9 @@
 		}
 	}
 
-	// Slow path: Either full scan was not requested, or projects exist in
-	// manifest that were not found locally.  Do a recursive scan of all projects
-	// under JIRI_ROOT.
+	// Slow path: Either full scan was requested, or projects exist in manifest
+	// that were not found locally.  Do a recursive scan of all projects under
+	// JIRI_ROOT.
 	projects := Projects{}
 	jirix.TimerPush("scan fs")
 	err := findLocalProjects(jirix, jirix.Root, projects)
@@ -441,18 +807,14 @@
 	if err != nil {
 		return nil, err
 	}
-	_, remoteProjects, _, _, err := readManifest(jirix, false)
+	_, remoteProjects, _, _, err := readManifest(jirix)
 	if err != nil {
 		return nil, err
 	}
 
 	// Compute difference between local and remote.
 	update := Update{}
-	ops, err := computeOperations(localProjects, remoteProjects, false)
-	if err != nil {
-		return nil, err
-	}
-
+	ops := computeOperations(localProjects, remoteProjects, false, nil)
 	s := jirix.NewSeq()
 	for _, op := range ops {
 		name := op.Project().Name
@@ -509,7 +871,7 @@
 // ReadManifest retrieves and parses the manifest that determines what
 // projects and tools are part of the jiri universe.
 func ReadManifest(jirix *jiri.X) (Projects, Tools, error) {
-	_, p, t, _, e := readManifest(jirix, false)
+	_, p, t, _, e := readManifest(jirix)
 	return p, t, e
 }
 
@@ -527,40 +889,59 @@
 		}, "get manifest origin").Done()
 }
 
-// readManifest implements the ReadManifest logic and provides an
-// optional flag that can be used to fetch the latest manifest updates
-// from the manifest repository.
-func readManifest(jirix *jiri.X, update bool) (Hosts, Projects, Tools, Hooks, error) {
+func readManifest(jirix *jiri.X) (Hosts, Projects, Tools, Hooks, error) {
 	jirix.TimerPush("read manifest")
 	defer jirix.TimerPop()
-	if update {
-		manifestPath := toAbs(jirix, ".manifest")
-		manifestRemote, err := getManifestRemote(jirix, manifestPath)
-		if err != nil {
-			return nil, nil, nil, nil, err
-		}
-		project := Project{
-			Path:         manifestPath,
-			Protocol:     "git",
-			Remote:       manifestRemote,
-			Revision:     "HEAD",
-			RemoteBranch: "master",
-		}
-		if err := resetProject(jirix, project); err != nil {
-			return nil, nil, nil, nil, err
-		}
-	}
-	path, err := jirix.ResolveManifestPath(jirix.Manifest())
+	file, err := jirix.ResolveManifestPath(jirix.Manifest())
 	if err != nil {
 		return nil, nil, nil, nil, err
 	}
-	hosts, projects, tools, hooks, stack := Hosts{}, Projects{}, Tools{}, Hooks{}, map[string]struct{}{}
-	if err := loadManifest(jirix, path, hosts, projects, tools, hooks, stack); err != nil {
+	var imp importer
+	hosts, projects, tools, hooks := Hosts{}, Projects{}, Tools{}, Hooks{}
+	if err := imp.Load(jirix, jirix.Root, file, "", hosts, projects, tools, hooks); err != nil {
 		return nil, nil, nil, nil, err
 	}
 	return hosts, projects, tools, hooks, nil
 }
 
+func updateManifestProjects(jirix *jiri.X) error {
+	jirix.TimerPush("update manifest")
+	defer jirix.TimerPop()
+	if jirix.UsingOldManifests() {
+		return updateManifestProjectsDeprecated(jirix)
+	}
+	// Update the repositories corresponding to all remote imports.
+	//
+	// TODO(toddw): Cache local projects in jirix, so that we don't need to
+	// perform multiple full scans.
+	localProjects, err := LocalProjects(jirix, FullScan)
+	if err != nil {
+		return err
+	}
+	file, err := jirix.ResolveManifestPath(jirix.Manifest())
+	if err != nil {
+		return err
+	}
+	var imp importer
+	return imp.Update(jirix, jirix.Root, file, "", localProjects)
+}
+
+func updateManifestProjectsDeprecated(jirix *jiri.X) error {
+	manifestPath := filepath.Join(jirix.Root, ".manifest")
+	manifestRemote, err := getManifestRemote(jirix, manifestPath)
+	if err != nil {
+		return err
+	}
+	project := Project{
+		Path:         manifestPath,
+		Protocol:     "git",
+		Remote:       manifestRemote,
+		Revision:     "HEAD",
+		RemoteBranch: "master",
+	}
+	return resetProject(jirix, project)
+}
+
 // UpdateUniverse updates all local projects and tools to match the
 // remote counterparts identified by the given manifest. Optionally,
 // the 'gc' flag can be used to indicate that local projects that no
@@ -568,13 +949,18 @@
 func UpdateUniverse(jirix *jiri.X, gc bool) (e error) {
 	jirix.TimerPush("update universe")
 	defer jirix.TimerPop()
-	_, remoteProjects, remoteTools, remoteHooks, err := readManifest(jirix, true)
+	// 0. Update all manifest projects to match their remote counterparts, and
+	// read the manifest file.
+	if err := updateManifestProjects(jirix); err != nil {
+		return err
+	}
+	remoteHosts, remoteProjects, remoteTools, remoteHooks, err := readManifest(jirix)
 	if err != nil {
 		return err
 	}
 	s := jirix.NewSeq()
 	// 1. Update all local projects to match their remote counterparts.
-	if err := updateProjects(jirix, remoteProjects, gc); err != nil {
+	if err := updateProjects(jirix, remoteProjects, gc, remoteHosts); err != nil {
 		return err
 	}
 	// 2. Build all tools in a temporary directory.
@@ -826,10 +1212,9 @@
 
 // isLocalProject returns true if there is a project at the given path.
 func isLocalProject(jirix *jiri.X, path string) (bool, error) {
-	absPath := toAbs(jirix, path)
 	// Existence of a metadata directory is how we know we've found a
 	// Jiri-maintained project.
-	metadataDir := filepath.Join(absPath, jiri.ProjectMetaDir)
+	metadataDir := filepath.Join(path, jiri.ProjectMetaDir)
 	if _, err := jirix.NewSeq().Stat(metadataDir); err != nil {
 		if runutil.IsNotExist(err) {
 			return false, nil
@@ -842,35 +1227,29 @@
 // projectAtPath returns a Project struct corresponding to the project at the
 // path in the filesystem.
 func projectAtPath(jirix *jiri.X, path string) (Project, error) {
-	var project Project
-	absPath := toAbs(jirix, path)
-	metadataFile := filepath.Join(absPath, jiri.ProjectMetaDir, jiri.ProjectMetaFile)
-	bytes, err := jirix.NewSeq().ReadFile(metadataFile)
+	metadataFile := filepath.Join(path, jiri.ProjectMetaDir, jiri.ProjectMetaFile)
+	project, err := ProjectFromFile(jirix, metadataFile)
 	if err != nil {
-		return project, err
+		return Project{}, err
 	}
-	if err := xml.Unmarshal(bytes, &project); err != nil {
-		return project, fmt.Errorf("Unmarshal() failed: %v\n%s", err, string(bytes))
-	}
-	project.Path = toAbs(jirix, project.Path)
-	return project, nil
+	project.Path = filepath.Join(jirix.Root, project.Path)
+	return *project, nil
 }
 
 // findLocalProjects scans the filesystem for all projects.  Note that project
 // directories can be nested recursively.
 func findLocalProjects(jirix *jiri.X, path string, projects Projects) error {
-	absPath := toAbs(jirix, path)
-	isLocal, err := isLocalProject(jirix, absPath)
+	isLocal, err := isLocalProject(jirix, path)
 	if err != nil {
 		return err
 	}
 	if isLocal {
-		project, err := projectAtPath(jirix, absPath)
+		project, err := projectAtPath(jirix, path)
 		if err != nil {
 			return err
 		}
-		if absPath != project.Path {
-			return fmt.Errorf("project %v has path %v but was found in %v", project.Name, project.Path, absPath)
+		if path != project.Path {
+			return fmt.Errorf("project %v has path %v but was found in %v", project.Name, project.Path, path)
 		}
 		if p, ok := projects[project.Key()]; ok {
 			return fmt.Errorf("name conflict: both %v and %v contain project with key %v", p.Path, project.Path, project.Key())
@@ -1039,111 +1418,190 @@
 	return ApplyToLocalMaster(jirix, Projects{project.Key(): project}, fn)
 }
 
-// loadManifest loads the given manifest, processing all of its
-// imports, projects and tools settings.
-func loadManifest(jirix *jiri.X, path string, hosts Hosts, projects Projects, tools Tools, hooks Hooks, stack map[string]struct{}) error {
-	data, err := jirix.NewSeq().ReadFile(path)
+// importer handles importing manifest files.  There are two uses: Load reads
+// full manifests into memory, while Update updates remote manifest projects.
+type importer struct {
+	cycleStack []cycleInfo
+}
+
+type cycleInfo struct {
+	file, key string
+}
+
+// importNoCycles checks for cycles in imports.  There are two types of cycles:
+//   file - Cycle in the paths of manifest files in the local filesystem.
+//   key  - Cycle in the remote manifests specified by remote imports.
+//
+// Example of file cycles.  File A imports file B, and vice versa.
+//     file=manifest/A              file=manifest/B
+//     <manifest>                   <manifest>
+//       <fileimport file="B"/>       <fileimport file="A"/>
+//     </manifest>                  </manifest>
+//
+// Example of key cycles.  The key consists of "remote/manifest", e.g.
+//   https://vanadium.googlesource.com/manifest/v2/default
+// In the example, key x/A imports y/B, and vice versa.
+//     key=x/A                                 key=y/B
+//     <manifest>                              <manifest>
+//       <import remote="y" manifest="B"/>       <import remote="x" manifest="A"/>
+//     </manifest>                             </manifest>
+//
+// The above examples are simple, but the general strategy is demonstrated.  We
+// keep a single stack for both files and keys, and push onto each stack before
+// running the recursive load or update function, and pop the stack when the
+// function is done.  If we see a duplicate on the stack at any point, we know
+// there's a cycle.  Note that we know the file for both local fileimports as
+// well as remote imports, but we only know the key for remote imports; the key
+// for local fileimports is empty.
+//
+// A more complex case would involve a combination of local fileimports and
+// remote imports, using the "root" attribute to change paths on the local
+// filesystem.  In this case the key will eventually expose the cycle.
+func (imp *importer) importNoCycles(file, key string, fn func() error) error {
+	info := cycleInfo{file, key}
+	for _, c := range imp.cycleStack {
+		if file == c.file {
+			return fmt.Errorf("import cycle detected in local manifest files: %q", append(imp.cycleStack, info))
+		}
+		if key != "" && key == c.key {
+			return fmt.Errorf("import cycle detected in remote manifest imports: %q", append(imp.cycleStack, info))
+		}
+	}
+	imp.cycleStack = append(imp.cycleStack, info)
+	if err := fn(); err != nil {
+		return err
+	}
+	imp.cycleStack = imp.cycleStack[:len(imp.cycleStack)-1]
+	return nil
+}
+
+func (imp *importer) Load(jirix *jiri.X, root, file, key string, hosts Hosts, projects Projects, tools Tools, hooks Hooks) error {
+	return imp.importNoCycles(file, key, func() error {
+		return imp.load(jirix, root, file, hosts, projects, tools, hooks)
+	})
+}
+
+func (imp *importer) load(jirix *jiri.X, root, file string, hosts Hosts, projects Projects, tools Tools, hooks Hooks) error {
+	m, err := ManifestFromFile(jirix, file)
 	if err != nil {
 		return err
 	}
-	m := &Manifest{}
-	if err := xml.Unmarshal(data, m); err != nil {
-		return fmt.Errorf("Unmarshal(%v) failed: %v", string(data), err)
-	}
 	// Process all imports.
-	for _, manifest := range m.Imports {
-		if _, ok := stack[manifest.Name]; ok {
-			return fmt.Errorf("import cycle encountered")
+	for _, _import := range m.Imports {
+		newRoot, newFile := root, ""
+		if _import.Remote != "" {
+			// New-style remote import
+			newRoot = filepath.Join(root, _import.Root)
+			newFile = filepath.Join(newRoot, _import.Path, _import.Manifest)
+		} else {
+			// Old-style name-based local import.
+			//
+			// TODO(toddw): Remove this logic when the manifest transition is done.
+			if newFile, err = jirix.ResolveManifestPath(_import.Name); err != nil {
+				return err
+			}
 		}
-		path, err := jirix.ResolveManifestPath(manifest.Name)
-		if err != nil {
+		if err := imp.Load(jirix, newRoot, newFile, _import.remoteKey(), hosts, projects, tools, hooks); err != nil {
 			return err
 		}
-		stack[manifest.Name] = struct{}{}
-		if err := loadManifest(jirix, path, hosts, projects, tools, hooks, stack); err != nil {
+	}
+	// Process all file imports.
+	for _, fileImport := range m.FileImports {
+		newFile := filepath.Join(filepath.Dir(file), fileImport.File)
+		if err := imp.Load(jirix, root, newFile, "", hosts, projects, tools, hooks); err != nil {
 			return err
 		}
-		delete(stack, manifest.Name)
 	}
 	// Process all projects.
 	for _, project := range m.Projects {
-		if strings.Contains(project.Name, projectKeySeparator) {
-			return fmt.Errorf("project name cannot contain %q: %q", projectKeySeparator, project.Name)
-		}
-		if project.Exclude {
-			// Exclude the project in case it was
-			// previously included.
-			delete(projects, project.Key())
-			continue
-		}
-		// Replace the relative path with an absolute one.
-		project.Path = toAbs(jirix, project.Path)
-		// Use git as the default protocol.
-		if project.Protocol == "" {
-			project.Protocol = "git"
-		}
-		// Use HEAD and tip as the default revision for git
-		// and mercurial respectively.
-		if project.Revision == "" {
-			switch project.Protocol {
-			case "git":
-				project.Revision = "HEAD"
-			default:
-				return UnsupportedProtocolErr(project.Protocol)
-			}
-		}
-		// Default to "master" branch if none is provided.
-		if project.RemoteBranch == "" {
-			project.RemoteBranch = "master"
-		}
+		project.Path = filepath.Join(root, project.Path)
 		projects[project.Key()] = project
 	}
 	// Process all tools.
 	for _, tool := range m.Tools {
-		if tool.Exclude {
-			// Exclude the tool in case it was previously
-			// included.
-			delete(tools, tool.Name)
-			continue
-		}
-		// Use <JiriProject> as the default project.
-		if tool.Project == "" {
-			tool.Project = "https://vanadium.googlesource.com/" + JiriProject
-		}
-		// Use "data" as the default data.
-		if tool.Data == "" {
-			tool.Data = "data"
-		}
 		tools[tool.Name] = tool
 	}
 	// Process all hooks.
 	for _, hook := range m.Hooks {
-		if hook.Exclude {
-			// Exclude the hook in case it was previously
-			// included.
-			delete(hooks, hook.Name)
-			continue
-		}
 		project, err := projects.FindUnique(hook.Project)
 		if err != nil {
 			return fmt.Errorf("error while finding project %q for hook %q: %v", hook.Project, hook.Name, err)
 		}
-		// Replace project-relative path with absolute path.
 		hook.Path = filepath.Join(project.Path, hook.Path)
 		hooks[hook.Name] = hook
 	}
 	// Process all hosts.
 	for _, host := range m.Hosts {
 		hosts[host.Name] = host
+	}
+	return nil
+}
 
-		// Sanity check that we only have githooks for git hosts.
-		if host.Name != "git" {
-			if len(host.GitHooks) > 0 {
-				return fmt.Errorf("githook provided for a non-Git host: %s", host.Location)
-			}
+func (imp *importer) Update(jirix *jiri.X, root, file, key string, localProjects Projects) error {
+	return imp.importNoCycles(file, key, func() error {
+		return imp.update(jirix, root, file, localProjects)
+	})
+}
+
+func (imp *importer) update(jirix *jiri.X, root, file string, localProjects Projects) error {
+	m, err := ManifestFromFile(jirix, file)
+	if err != nil {
+		return err
+	}
+	// Process all remote imports.  This logic treats the remote import as a
+	// regular project, and runs our regular create/move/update logic on it.  We
+	// never handle deletes here; those are handled in updateProjects.
+	for _, remote := range m.Imports {
+		if remote.Remote == "" {
+			// Old-style local imports handled in loop below.
+			continue
+		}
+		newRoot := filepath.Join(root, remote.Root)
+		remote.Path = filepath.Join(newRoot, remote.Path)
+		newFile := filepath.Join(remote.Path, remote.Manifest)
+		var localProject *Project
+		if p, ok := localProjects[remote.Project.Key()]; ok {
+			localProject = &p
+		}
+		// Since &remote.Project is never nil, we'll never produce a delete op.
+		//
+		// TODO(toddw): How do we retrieve the hosts, which are necessary for
+		// githooks to be installed during the create operation?
+		op := computeOp(localProject, &remote.Project, false, nil)
+		if err := op.Test(jirix, newFsUpdates()); err != nil {
+			return err
+		}
+		updateFn := func() error { return op.Run(jirix, nil) }
+		if err := jirix.NewSeq().Verbose(true).Call(updateFn, "%v", op).Done(); err != nil {
+			fmt.Fprintf(jirix.Stderr(), "%v\n", err)
+			return err
+		}
+		localProjects[remote.Project.Key()] = remote.Project
+		if err := imp.Update(jirix, newRoot, newFile, remote.remoteKey(), localProjects); err != nil {
+			return err
 		}
 	}
-
+	// Process all old-style local imports.
+	for _, local := range m.Imports {
+		if local.Remote != "" {
+			// New-style remote imports handled in loop above.
+			continue
+		}
+		newFile, err := jirix.ResolveManifestPath(local.Name)
+		if err != nil {
+			return err
+		}
+		if err := imp.Update(jirix, root, newFile, "", localProjects); err != nil {
+			return err
+		}
+	}
+	// Process all file imports.
+	for _, fileImport := range m.FileImports {
+		newFile := filepath.Join(filepath.Dir(file), fileImport.File)
+		if err := imp.Update(jirix, root, newFile, "", localProjects); err != nil {
+			return err
+		}
+	}
 	return nil
 }
 
@@ -1216,7 +1674,7 @@
 	}
 }
 
-func updateProjects(jirix *jiri.X, remoteProjects Projects, gc bool) error {
+func updateProjects(jirix *jiri.X, remoteProjects Projects, gc bool, hosts Hosts) error {
 	jirix.TimerPush("update projects")
 	defer jirix.TimerPop()
 
@@ -1229,11 +1687,7 @@
 		return err
 	}
 	getRemoteHeadRevisions(jirix, remoteProjects)
-	ops, err := computeOperations(localProjects, remoteProjects, gc)
-	if err != nil {
-		return err
-	}
-
+	ops := computeOperations(localProjects, remoteProjects, gc, hosts)
 	updates := newFsUpdates()
 	for _, op := range ops {
 		if err := op.Test(jirix, updates); err != nil {
@@ -1279,22 +1733,13 @@
 
 	// Replace absolute project paths with relative paths to make it
 	// possible to move the $JIRI_ROOT directory locally.
-	relPath, err := toRel(jirix, project.Path)
+	relPath, err := filepath.Rel(jirix.Root, project.Path)
 	if err != nil {
 		return err
 	}
 	project.Path = relPath
-	bytes, err := xml.Marshal(project)
-	if err != nil {
-		return fmt.Errorf("Marhsal() failed: %v", err)
-	}
 	metadataFile := filepath.Join(metadataDir, jiri.ProjectMetaFile)
-	tmpMetadataFile := metadataFile + ".tmp"
-	if err := s.WriteFile(tmpMetadataFile, bytes, os.FileMode(0644)).
-		Rename(tmpMetadataFile, metadataFile).Done(); err != nil {
-		return err
-	}
-	return nil
+	return project.ToFile(jirix, metadataFile)
 }
 
 // addProjectToManifest records the information about the given
@@ -1305,6 +1750,9 @@
 // NOTE: The function assumes that the the given project is on a
 // master branch.
 func addProjectToManifest(jirix *jiri.X, manifest *Manifest, project Project) error {
+	if manifest == nil {
+		return nil
+	}
 	// If the project uses relative revision, replace it with an absolute one.
 	switch project.Protocol {
 	case "git":
@@ -1318,7 +1766,7 @@
 	default:
 		return UnsupportedProtocolErr(project.Protocol)
 	}
-	relPath, err := toRel(jirix, project.Path)
+	relPath, err := filepath.Rel(jirix.Root, project.Path)
 	if err != nil {
 		return err
 	}
@@ -1382,13 +1830,10 @@
 // createOperation represents the creation of a project.
 type createOperation struct {
 	commonOperation
+	hosts Hosts
 }
 
 func (op createOperation) Run(jirix *jiri.X, manifest *Manifest) (e error) {
-	hosts, _, _, _, err := readManifest(jirix, false)
-	if err != nil {
-		return err
-	}
 	s := jirix.NewSeq()
 
 	path, perm := filepath.Dir(op.destination), os.FileMode(0755)
@@ -1412,7 +1857,7 @@
 		// overriding existing hooks.  Customizing your git hooks with jiri is a bad
 		// idea anyway, since jiri won't know to not delete the project when you
 		// switch between manifests or do a cleanup.
-		host, found := hosts["git"]
+		host, found := op.hosts["git"]
 		if found && strings.HasPrefix(op.project.Remote, host.Location) {
 			gitHookDir := filepath.Join(tmpDir, ".git", "hooks")
 			for _, githook := range host.GitHooks {
@@ -1551,8 +1996,7 @@
 func (op moveOperation) Run(jirix *jiri.X, manifest *Manifest) error {
 	s := jirix.NewSeq()
 	path, perm := filepath.Dir(op.destination), os.FileMode(0755)
-	if err := s.MkdirAll(path, perm).
-		Rename(op.source, op.destination).Done(); err != nil {
+	if err := s.MkdirAll(path, perm).Rename(op.source, op.destination).Done(); err != nil {
 		return err
 	}
 	if err := reportNonMaster(jirix, op.project); err != nil {
@@ -1686,60 +2130,68 @@
 // system and manifest file respectively) and outputs a collection of
 // operations that describe the actions needed to update the target
 // projects.
-func computeOperations(localProjects, remoteProjects Projects, gc bool) (operations, error) {
+func computeOperations(localProjects, remoteProjects Projects, gc bool, hosts Hosts) operations {
 	result := operations{}
-	allProjects := map[ProjectKey]struct{}{}
+	allProjects := map[ProjectKey]bool{}
 	for _, p := range localProjects {
-		allProjects[p.Key()] = struct{}{}
+		allProjects[p.Key()] = true
 	}
 	for _, p := range remoteProjects {
-		allProjects[p.Key()] = struct{}{}
+		allProjects[p.Key()] = true
 	}
 	for key, _ := range allProjects {
-		if localProject, ok := localProjects[key]; ok {
-			if remoteProject, ok := remoteProjects[key]; ok {
-				if localProject.Path != remoteProject.Path {
-					// moveOperation also does an update, so we don't need to
-					// check the revision here.
-					result = append(result, moveOperation{commonOperation{
-						destination: remoteProject.Path,
-						project:     remoteProject,
-						source:      localProject.Path,
-					}})
-				} else {
-					if localProject.Revision != remoteProject.Revision {
-						result = append(result, updateOperation{commonOperation{
-							destination: remoteProject.Path,
-							project:     remoteProject,
-							source:      localProject.Path,
-						}})
-					} else {
-						result = append(result, nullOperation{commonOperation{
-							destination: remoteProject.Path,
-							project:     remoteProject,
-							source:      localProject.Path,
-						}})
-					}
-				}
-			} else {
-				result = append(result, deleteOperation{commonOperation{
-					destination: "",
-					project:     localProject,
-					source:      localProject.Path,
-				}, gc})
-			}
-		} else if remoteProject, ok := remoteProjects[key]; ok {
-			result = append(result, createOperation{commonOperation{
-				destination: remoteProject.Path,
-				project:     remoteProject,
-				source:      "",
-			}})
-		} else {
-			return nil, fmt.Errorf("project with key %v does not exist", key)
+		var local, remote *Project
+		if project, ok := localProjects[key]; ok {
+			local = &project
 		}
+		if project, ok := remoteProjects[key]; ok {
+			remote = &project
+		}
+		result = append(result, computeOp(local, remote, gc, hosts))
 	}
 	sort.Sort(result)
-	return result, nil
+	return result
+}
+
+func computeOp(local, remote *Project, gc bool, hosts Hosts) operation {
+	switch {
+	case local != nil && remote != nil:
+		if local.Path != remote.Path {
+			// moveOperation also does an update, so we don't need to check the
+			// revision here.
+			return moveOperation{commonOperation{
+				destination: remote.Path,
+				project:     *remote,
+				source:      local.Path,
+			}}
+		}
+		if local.Revision != remote.Revision {
+			return updateOperation{commonOperation{
+				destination: remote.Path,
+				project:     *remote,
+				source:      local.Path,
+			}}
+		}
+		return nullOperation{commonOperation{
+			destination: remote.Path,
+			project:     *remote,
+			source:      local.Path,
+		}}
+	case local != nil && remote == nil:
+		return deleteOperation{commonOperation{
+			destination: "",
+			project:     *local,
+			source:      local.Path,
+		}, gc}
+	case local == nil && remote != nil:
+		return createOperation{commonOperation{
+			destination: remote.Path,
+			project:     *remote,
+			source:      "",
+		}, hosts}
+	default:
+		panic("jiri: computeOp called with nil local and remote")
+	}
 }
 
 // ParseNames identifies the set of projects that a jiri command should
diff --git a/project/project_test.go b/project/project_test.go
index 03eb785..a0bf4a0 100644
--- a/project/project_test.go
+++ b/project/project_test.go
@@ -6,7 +6,6 @@
 
 import (
 	"bytes"
-	"encoding/xml"
 	"fmt"
 	"io/ioutil"
 	"os"
@@ -68,40 +67,39 @@
 
 func createLocalManifestCopy(t *testing.T, jirix *jiri.X, dir, manifestDir string) {
 	// Load the remote manifest.
-	manifestFile := filepath.Join(manifestDir, "v2", "default")
-	data, err := ioutil.ReadFile(manifestFile)
+	m, err := project.ManifestFromFile(jirix, filepath.Join(manifestDir, "v2", "default"))
 	if err != nil {
-		t.Fatalf("ReadFile(%v) failed: %v", manifestFile, err)
+		t.Fatal(err)
 	}
-	manifest := project.Manifest{}
-	if err := xml.Unmarshal(data, &manifest); err != nil {
-		t.Fatalf("Unmarshal() failed: %v\n%v", err, data)
-	}
-
 	// Store the manifest locally.
-	data, err = xml.Marshal(manifest)
-	if err != nil {
-		t.Fatalf("%v", err)
-	}
-	manifestFile, perm := filepath.Join(dir, ".local_manifest"), os.FileMode(0644)
-	if err := ioutil.WriteFile(manifestFile, data, perm); err != nil {
-		t.Fatalf("WriteFile(%v, %v) failed: %v", manifestFile, err, perm)
+	if err := m.ToFile(jirix, filepath.Join(dir, ".local_manifest")); err != nil {
+		t.Fatal(err)
 	}
 }
 
 func createLocalManifestStub(t *testing.T, jirix *jiri.X, dir string) {
 	// Create a manifest stub.
 	manifest := project.Manifest{}
-	manifest.Imports = append(manifest.Imports, project.Import{Name: "default"})
-
+	imp := project.Import{}
+	imp.Name = "default"
+	manifest.Imports = append(manifest.Imports, imp)
 	// Store the manifest locally.
-	data, err := xml.Marshal(manifest)
-	if err != nil {
-		t.Fatalf("%v", err)
+	if err := manifest.ToFile(jirix, filepath.Join(dir, ".local_manifest")); err != nil {
+		t.Fatal(err)
 	}
-	manifestFile, perm := filepath.Join(dir, ".local_manifest"), os.FileMode(0644)
-	if err := ioutil.WriteFile(manifestFile, data, perm); err != nil {
-		t.Fatalf("WriteFile(%v, %v) failed: %v", manifestFile, err, perm)
+}
+
+func commitFile(t *testing.T, jirix *jiri.X, dir, file, msg string) {
+	cwd, err := os.Getwd()
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer jirix.NewSeq().Chdir(cwd)
+	if err := jirix.NewSeq().Chdir(dir).Done(); err != nil {
+		t.Fatal(err)
+	}
+	if err := jirix.Git().CommitFile(file, msg); err != nil {
+		t.Fatal(err)
 	}
 }
 
@@ -134,53 +132,36 @@
 }
 
 func commitManifest(t *testing.T, jirix *jiri.X, manifest *project.Manifest, manifestDir string) {
-	data, err := xml.Marshal(*manifest)
-	if err != nil {
-		t.Fatalf("%v", err)
+	manifestFile := filepath.Join(manifestDir, "v2", "default")
+	if err := manifest.ToFile(jirix, manifestFile); err != nil {
+		t.Fatal(err)
 	}
-	manifestFile, perm := filepath.Join(manifestDir, "v2", "default"), os.FileMode(0644)
-	if err := ioutil.WriteFile(manifestFile, data, perm); err != nil {
-		t.Fatalf("WriteFile(%v, %v) failed: %v", manifestFile, err, perm)
-	}
-	cwd, err := os.Getwd()
-	if err != nil {
-		t.Fatalf("%v", err)
-	}
-	defer jirix.NewSeq().Chdir(cwd)
-	if err := jirix.NewSeq().Chdir(manifestDir).Done(); err != nil {
-		t.Fatalf("%v", err)
-	}
-	if err := jirix.Git().CommitFile(manifestFile, "creating manifest"); err != nil {
-		t.Fatalf("%v", err)
-	}
+	commitFile(t, jirix, manifestDir, manifestFile, "creating manifest")
 }
 
 func createProject(t *testing.T, jirix *jiri.X, manifestDir, name, remote, path string) {
-	manifestFile := filepath.Join(manifestDir, "v2", "default")
-	data, err := ioutil.ReadFile(manifestFile)
+	m, err := project.ManifestFromFile(jirix, filepath.Join(manifestDir, "v2", "default"))
 	if err != nil {
-		t.Fatalf("ReadFile(%v) failed: %v", manifestFile, err)
+		t.Fatal(err)
 	}
-	manifest := project.Manifest{}
-	if err := xml.Unmarshal(data, &manifest); err != nil {
-		t.Fatalf("Unmarshal() failed: %v\n%v", err, data)
-	}
-	manifest.Projects = append(manifest.Projects, project.Project{Name: name, Remote: remote, Path: path})
-	commitManifest(t, jirix, &manifest, manifestDir)
+	m.Projects = append(m.Projects, project.Project{Name: name, Remote: remote, Path: path})
+	commitManifest(t, jirix, m, manifestDir)
 }
 
 func deleteProject(t *testing.T, jirix *jiri.X, manifestDir, remote string) {
-	manifestFile := filepath.Join(manifestDir, "v2", "default")
-	data, err := ioutil.ReadFile(manifestFile)
+	m, err := project.ManifestFromFile(jirix, filepath.Join(manifestDir, "v2", "default"))
 	if err != nil {
-		t.Fatalf("ReadFile(%v) failed: %v", manifestFile, err)
+		t.Fatal(err)
 	}
-	manifest := project.Manifest{}
-	if err := xml.Unmarshal(data, &manifest); err != nil {
-		t.Fatalf("Unmarshal() failed: %v\n%v", err, data)
+	deleteKey := project.MakeProjectKey(remote, remote)
+	var projects []project.Project
+	for _, p := range m.Projects {
+		if p.Key() != deleteKey {
+			projects = append(projects, p)
+		}
 	}
-	manifest.Projects = append(manifest.Projects, project.Project{Exclude: true, Name: remote, Remote: remote})
-	commitManifest(t, jirix, &manifest, manifestDir)
+	m.Projects = projects
+	commitManifest(t, jirix, m, manifestDir)
 }
 
 // Identify the current revision for a given project.
@@ -202,20 +183,15 @@
 
 // Fix the revision in the manifest file.
 func setRevisionForProject(t *testing.T, jirix *jiri.X, manifestDir, name, revision string) {
-	manifestFile := filepath.Join(manifestDir, "v2", "default")
-	data, err := ioutil.ReadFile(manifestFile)
+	m, err := project.ManifestFromFile(jirix, filepath.Join(manifestDir, "v2", "default"))
 	if err != nil {
-		t.Fatalf("ReadFile(%v) failed: %v", manifestFile, err)
-	}
-	manifest := project.Manifest{}
-	if err := xml.Unmarshal(data, &manifest); err != nil {
-		t.Fatalf("Unmarshal() failed: %v\n%v", err, data)
+		t.Fatal(err)
 	}
 	updated := false
-	for i, p := range manifest.Projects {
+	for i, p := range m.Projects {
 		if p.Name == name {
 			p.Revision = revision
-			manifest.Projects[i] = p
+			m.Projects[i] = p
 			updated = true
 			break
 		}
@@ -223,7 +199,7 @@
 	if !updated {
 		t.Fatalf("failed to fix revision for project %v", name)
 	}
-	commitManifest(t, jirix, &manifest, manifestDir)
+	commitManifest(t, jirix, m, manifestDir)
 }
 
 func holdProjectBack(t *testing.T, jirix *jiri.X, manifestDir, name string) {
@@ -236,20 +212,15 @@
 }
 
 func moveProject(t *testing.T, jirix *jiri.X, manifestDir, name, dst string) {
-	manifestFile := filepath.Join(manifestDir, "v2", "default")
-	data, err := ioutil.ReadFile(manifestFile)
+	m, err := project.ManifestFromFile(jirix, filepath.Join(manifestDir, "v2", "default"))
 	if err != nil {
-		t.Fatalf("ReadFile(%v) failed: %v", manifestFile, err)
-	}
-	manifest := project.Manifest{}
-	if err := xml.Unmarshal(data, &manifest); err != nil {
-		t.Fatalf("Unmarshal() failed: %v\n%v", err, data)
+		t.Fatal(err)
 	}
 	updated := false
-	for i, p := range manifest.Projects {
+	for i, p := range m.Projects {
 		if p.Name == name {
 			p.Path = dst
-			manifest.Projects[i] = p
+			m.Projects[i] = p
 			updated = true
 			break
 		}
@@ -257,7 +228,7 @@
 	if !updated {
 		t.Fatalf("failed to set path for project %v", name)
 	}
-	commitManifest(t, jirix, &manifest, manifestDir)
+	commitManifest(t, jirix, m, manifestDir)
 }
 
 func remoteProjectName(i int) string {
@@ -297,21 +268,10 @@
 }
 
 func writeEmptyMetadata(t *testing.T, jirix *jiri.X, projectDir string) {
-	s := jirix.NewSeq()
-	if err := s.Chdir(projectDir).Done(); err != nil {
-		t.Fatalf("%v", err)
-	}
-	metadataDir := filepath.Join(projectDir, jiri.ProjectMetaDir)
-	if err := s.MkdirAll(metadataDir, os.FileMode(0755)).Done(); err != nil {
-		t.Fatalf("%v", err)
-	}
-	bytes, err := xml.Marshal(project.Project{})
-	if err != nil {
-		t.Fatalf("Marshal() failed: %v", err)
-	}
-	metadataFile := filepath.Join(metadataDir, jiri.ProjectMetaFile)
-	if err := s.WriteFile(metadataFile, bytes, os.FileMode(0644)).Done(); err != nil {
-		t.Fatalf("%v", err)
+	metadataFile := filepath.Join(projectDir, jiri.ProjectMetaDir, jiri.ProjectMetaFile)
+	p := project.Project{}
+	if err := p.ToFile(jirix, metadataFile); err != nil {
+		t.Fatal(err)
 	}
 }
 
@@ -320,17 +280,7 @@
 	if err := ioutil.WriteFile(path, []byte(message), perm); err != nil {
 		t.Fatalf("WriteFile(%v, %v) failed: %v", path, perm, err)
 	}
-	cwd, err := os.Getwd()
-	if err != nil {
-		t.Fatalf("%v", err)
-	}
-	defer jirix.NewSeq().Chdir(cwd)
-	if err := jirix.NewSeq().Chdir(projectDir).Done(); err != nil {
-		t.Fatalf("%v", err)
-	}
-	if err := jirix.Git().CommitFile(path, "creating README"); err != nil {
-		t.Fatalf("%v", err)
-	}
+	commitFile(t, jirix, projectDir, path, "creating README")
 }
 
 func createAndCheckoutBranch(t *testing.T, jirix *jiri.X, projectDir, branch string) {
@@ -433,7 +383,7 @@
 // TODO(jsimsa): Add tests for the logic that updates tools.
 func TestUpdateUniverse(t *testing.T) {
 	// Setup an instance of jiri universe, creating the remote repositories for
-	// the manifest and projects under the "remote" directory, which is ignored
+	// the manifest and projects under the ".remote" directory, which is ignored
 	// from the consideration of LocalProjects().
 	jirix, cleanup := jiritest.NewX(t)
 	defer cleanup()
@@ -563,9 +513,8 @@
 
 	// Delete a project and create a new one with a different name but the same
 	// path.  Check that UpdateUniverse() does not fail.
-	path := filepath.Join(localDir, localProjectName(4))
 	deleteProject(t, jirix, remoteManifest, remoteProjects[4])
-	createProject(t, jirix, remoteManifest, "new.project", remoteProjects[4], path)
+	createProject(t, jirix, remoteManifest, "new.project", remoteProjects[4], localProjectName(4))
 	if err := project.UpdateUniverse(jirix, true); err != nil {
 		t.Fatalf("%v", err)
 	}
@@ -620,6 +569,164 @@
 	}
 }
 
+func TestFileImportCycle(t *testing.T) {
+	jirix, cleanup := jiritest.NewX(t)
+	defer cleanup()
+
+	// Set up the cycle .jiri_manifest -> A -> B -> A
+	jiriManifest := project.Manifest{
+		FileImports: []project.FileImport{
+			{File: "A"},
+		},
+	}
+	manifestA := project.Manifest{
+		FileImports: []project.FileImport{
+			{File: "B"},
+		},
+	}
+	manifestB := project.Manifest{
+		FileImports: []project.FileImport{
+			{File: "A"},
+		},
+	}
+	if err := jiriManifest.ToFile(jirix, jirix.JiriManifestFile()); err != nil {
+		t.Fatal(err)
+	}
+	if err := manifestA.ToFile(jirix, filepath.Join(jirix.Root, "A")); err != nil {
+		t.Fatal(err)
+	}
+	if err := manifestB.ToFile(jirix, filepath.Join(jirix.Root, "B")); err != nil {
+		t.Fatal(err)
+	}
+
+	// The update should complain about the cycle.
+	err := project.UpdateUniverse(jirix, false)
+	if got, want := fmt.Sprint(err), "import cycle detected in local manifest files"; !strings.Contains(got, want) {
+		t.Errorf("got error %v, want substr %v", got, want)
+	}
+}
+
+func TestRemoteImportCycle(t *testing.T) {
+	jirix, cleanup := jiritest.NewX(t)
+	defer cleanup()
+	remoteDir := filepath.Join(jirix.Root, ".remote")
+
+	// Set up two remote manifest projects, remote1 and remote2.
+	remote1 := setupNewProject(t, jirix, remoteDir, "remote1", true)
+	remote2 := setupNewProject(t, jirix, remoteDir, "remote2", true)
+	fileA, fileB := filepath.Join(remote1, "A"), filepath.Join(remote2, "B")
+
+	// Set up the cycle .jiri_manifest -> remote1+A -> remote2+B -> remote1+A
+	jiriManifest := project.Manifest{
+		Imports: []project.Import{
+			{Manifest: "A", Project: project.Project{
+				Name: "n1", Path: "p1", Remote: remote1,
+			}},
+		},
+	}
+	manifestA := project.Manifest{
+		Imports: []project.Import{
+			{Manifest: "B", Project: project.Project{
+				Name: "n2", Path: "p2", Remote: remote2,
+			}},
+		},
+	}
+	manifestB := project.Manifest{
+		Imports: []project.Import{
+			{Manifest: "A", Project: project.Project{
+				Name: "n3", Path: "p3", Remote: remote1,
+			}},
+		},
+	}
+	if err := jiriManifest.ToFile(jirix, jirix.JiriManifestFile()); err != nil {
+		t.Fatal(err)
+	}
+	if err := manifestA.ToFile(jirix, fileA); err != nil {
+		t.Fatal(err)
+	}
+	if err := manifestB.ToFile(jirix, fileB); err != nil {
+		t.Fatal(err)
+	}
+	commitFile(t, jirix, remote1, fileA, "commit A")
+	commitFile(t, jirix, remote2, fileB, "commit B")
+
+	// The update should complain about the cycle.
+	err := project.UpdateUniverse(jirix, false)
+	if got, want := fmt.Sprint(err), "import cycle detected in remote manifest imports"; !strings.Contains(got, want) {
+		t.Errorf("got error %v, want substr %v", got, want)
+	}
+}
+
+func TestFileAndRemoteImportCycle(t *testing.T) {
+	jirix, cleanup := jiritest.NewX(t)
+	defer cleanup()
+	remoteDir := filepath.Join(jirix.Root, ".remote")
+
+	// Set up two remote manifest projects, remote1 and remote2.
+	remote1 := setupNewProject(t, jirix, remoteDir, "remote1", true)
+	remote2 := setupNewProject(t, jirix, remoteDir, "remote2", true)
+	fileA, fileD := filepath.Join(remote1, "A"), filepath.Join(remote1, "D")
+	fileB, fileC := filepath.Join(remote2, "B"), filepath.Join(remote2, "C")
+
+	// Set up the cycle .jiri_manifest -> remote1+A -> remote2+B -> C -> remote1+D -> A
+	jiriManifest := project.Manifest{
+		Imports: []project.Import{
+			{Manifest: "A", Root: "r1", Project: project.Project{
+				Name: "n1", Path: "p1", Remote: remote1,
+			}},
+		},
+	}
+	manifestA := project.Manifest{
+		Imports: []project.Import{
+			{Manifest: "B", Root: "r2", Project: project.Project{
+				Name: "n2", Path: "p2", Remote: remote2,
+			}},
+		},
+	}
+	manifestB := project.Manifest{
+		FileImports: []project.FileImport{
+			{File: "C"},
+		},
+	}
+	manifestC := project.Manifest{
+		Imports: []project.Import{
+			{Manifest: "D", Root: "r3", Project: project.Project{
+				Name: "n3", Path: "p3", Remote: remote1,
+			}},
+		},
+	}
+	manifestD := project.Manifest{
+		FileImports: []project.FileImport{
+			{File: "A"},
+		},
+	}
+	if err := jiriManifest.ToFile(jirix, jirix.JiriManifestFile()); err != nil {
+		t.Fatal(err)
+	}
+	if err := manifestA.ToFile(jirix, fileA); err != nil {
+		t.Fatal(err)
+	}
+	if err := manifestB.ToFile(jirix, fileB); err != nil {
+		t.Fatal(err)
+	}
+	if err := manifestC.ToFile(jirix, fileC); err != nil {
+		t.Fatal(err)
+	}
+	if err := manifestD.ToFile(jirix, fileD); err != nil {
+		t.Fatal(err)
+	}
+	commitFile(t, jirix, remote1, fileA, "commit A")
+	commitFile(t, jirix, remote2, fileB, "commit B")
+	commitFile(t, jirix, remote2, fileC, "commit C")
+	commitFile(t, jirix, remote1, fileD, "commit D")
+
+	// The update should complain about the cycle.
+	err := project.UpdateUniverse(jirix, false)
+	if got, want := fmt.Sprint(err), "import cycle detected"; !strings.Contains(got, want) {
+		t.Errorf("got error %v, want substr %v", got, want)
+	}
+}
+
 // TestUnsupportedProtocolErr checks that calling
 // UnsupportedPrototoclErr.Error() does not result in an infinite loop.
 func TestUnsupportedPrototocolErr(t *testing.T) {
@@ -800,3 +907,278 @@
 	}
 	return nil
 }
+
+func TestManifestToFromBytes(t *testing.T) {
+	tests := []struct {
+		Manifest project.Manifest
+		XML      string
+	}{
+		{
+			project.Manifest{},
+			`<manifest>
+</manifest>
+`,
+		},
+		{
+			project.Manifest{
+				Label: "label",
+				Hooks: []project.Hook{
+					{Name: "hook"},
+				},
+				Hosts: []project.Host{
+					{
+						Name: "git",
+						GitHooks: []project.GitHook{
+							{Name: "githook"},
+						},
+					},
+				},
+				Imports: []project.Import{
+					{
+						Manifest: "manifest",
+						Project: project.Project{
+							Path:         "manifest",
+							Protocol:     "git",
+							Remote:       "remote",
+							RemoteBranch: "master",
+							Revision:     "HEAD",
+						},
+					},
+					{Project: project.Project{Name: "localimport"}},
+				},
+				FileImports: []project.FileImport{
+					{File: "fileimport"},
+				},
+				Projects: []project.Project{
+					{
+						Name:         "project",
+						Path:         "path",
+						Protocol:     "git",
+						Remote:       "remote",
+						RemoteBranch: "otherbranch",
+						Revision:     "rev",
+					},
+				},
+				Tools: []project.Tool{
+					{
+						Data:    "tooldata",
+						Name:    "tool",
+						Project: "toolproject",
+					},
+				},
+			},
+			`<manifest label="label">
+  <hooks>
+    <hook name="hook"/>
+  </hooks>
+  <hosts>
+    <host name="git">
+      <githooks>
+        <githook name="githook"/>
+      </githooks>
+    </host>
+  </hosts>
+  <imports>
+    <import manifest="manifest" remote="remote"/>
+    <import name="localimport"/>
+    <fileimport file="fileimport"/>
+  </imports>
+  <projects>
+    <project name="project" path="path" remote="remote" remotebranch="otherbranch" revision="rev"/>
+  </projects>
+  <tools>
+    <tool data="tooldata" name="tool" project="toolproject"/>
+  </tools>
+</manifest>
+`,
+		},
+	}
+	for _, test := range tests {
+		gotBytes, err := test.Manifest.ToBytes()
+		if err != nil {
+			t.Errorf("%+v ToBytes failed: %v", test.Manifest, err)
+		}
+		if got, want := string(gotBytes), test.XML; got != want {
+			t.Errorf("%+v ToBytes GOT\n%v\nWANT\n%v", test.Manifest, got, want)
+		}
+		manifest, err := project.ManifestFromBytes([]byte(test.XML))
+		if err != nil {
+			t.Errorf("%+v FromBytes failed: %v", test.Manifest, err)
+		}
+		if got, want := manifest, &test.Manifest; !reflect.DeepEqual(got, want) {
+			t.Errorf("%+v FromBytes got %#v, want %#v", test.Manifest, got, want)
+		}
+	}
+}
+
+func TestProjectToFromFile(t *testing.T) {
+	jirix, cleanup := jiritest.NewX(t)
+	defer cleanup()
+
+	tests := []struct {
+		Project project.Project
+		XML     string
+	}{
+		{
+			// Default fields are dropped when marshaled, and added when unmarshaled.
+			project.Project{
+				Name:         "project",
+				Path:         "path",
+				Protocol:     "git",
+				Remote:       "remote",
+				RemoteBranch: "master",
+				Revision:     "HEAD",
+			},
+			`<project name="project" path="path" remote="remote"/>`,
+		},
+		{
+			project.Project{
+				Name:         "project",
+				Path:         "path",
+				Protocol:     "git",
+				Remote:       "remote",
+				RemoteBranch: "otherbranch",
+				Revision:     "rev",
+			},
+			`<project name="project" path="path" remote="remote" remotebranch="otherbranch" revision="rev"/>`,
+		},
+	}
+	for index, test := range tests {
+		filename := filepath.Join(jirix.Root, fmt.Sprintf("test-%d", index))
+		if err := test.Project.ToFile(jirix, filename); err != nil {
+			t.Errorf("%+v ToFile failed: %v", test.Project, err)
+		}
+		gotBytes, err := jirix.NewSeq().ReadFile(filename)
+		if err != nil {
+			t.Errorf("%+v ReadFile failed: %v", test.Project, err)
+		}
+		if got, want := string(gotBytes), test.XML; got != want {
+			t.Errorf("%+v ToFile GOT\n%v\nWANT\n%v", test.Project, got, want)
+		}
+		project, err := project.ProjectFromFile(jirix, filename)
+		if err != nil {
+			t.Errorf("%+v FromFile failed: %v", test.Project, err)
+		}
+		if got, want := project, &test.Project; !reflect.DeepEqual(got, want) {
+			t.Errorf("%+v FromFile got %#v, want %#v", test.Project, got, want)
+		}
+	}
+}
+
+func TestProjectFromFileBackwardsCompatible(t *testing.T) {
+	jirix, cleanup := jiritest.NewX(t)
+	defer cleanup()
+
+	tests := []struct {
+		XML     string
+		Project project.Project
+	}{
+		{
+			`<Project name="project" path="path" remote="remote"/>`,
+			project.Project{
+				Name:         "project",
+				Path:         "path",
+				Protocol:     "git",
+				Remote:       "remote",
+				RemoteBranch: "master",
+				Revision:     "HEAD",
+			},
+		},
+		{
+			`<Project name="project" path="path" remote="remote"></Project>`,
+			project.Project{
+				Name:         "project",
+				Path:         "path",
+				Protocol:     "git",
+				Remote:       "remote",
+				RemoteBranch: "master",
+				Revision:     "HEAD",
+			},
+		},
+		{
+			`<Project exclude="false" name="project" path="path" remote="remote" remotebranch="otherbranch" revision="rev"></Project>`,
+			project.Project{
+				Name:         "project",
+				Path:         "path",
+				Protocol:     "git",
+				Remote:       "remote",
+				RemoteBranch: "otherbranch",
+				Revision:     "rev",
+			},
+		},
+	}
+	for index, test := range tests {
+		filename := filepath.Join(jirix.Root, fmt.Sprintf("test-%d", index))
+		if err := jirix.NewSeq().WriteFile(filename, []byte(test.XML), 0644).Done(); err != nil {
+			t.Errorf("%+v WriteFile failed: %v", test.Project, err)
+		}
+		project, err := project.ProjectFromFile(jirix, filename)
+		if err != nil {
+			t.Errorf("%+v FromFile failed: %v", test.Project, err)
+		}
+		if got, want := project, &test.Project; !reflect.DeepEqual(got, want) {
+			t.Errorf("%+v FromFile got %#v, want %#v", test.Project, got, want)
+		}
+	}
+}
+
+func TestImportToFile(t *testing.T) {
+	jirix, cleanup := jiritest.NewX(t)
+	defer cleanup()
+
+	tests := []struct {
+		Import project.Import
+		XML    string
+	}{
+		{
+			project.Import{
+				Project: project.Project{
+					Name: "import",
+				},
+			},
+			`<import name="import"/>`,
+		},
+		{
+			// Default fields are dropped when marshaled, and added when unmarshaled.
+			project.Import{
+				Manifest: "manifest",
+				Project: project.Project{
+					Name:         "import",
+					Path:         "manifest",
+					Protocol:     "git",
+					Remote:       "remote",
+					RemoteBranch: "master",
+					Revision:     "HEAD",
+				},
+			},
+			`<import manifest="manifest" name="import" remote="remote"/>`,
+		},
+		{
+			project.Import{
+				Manifest: "manifest",
+				Project: project.Project{
+					Name:         "import",
+					Path:         "path",
+					Protocol:     "git",
+					Remote:       "remote",
+					RemoteBranch: "otherbranch",
+					Revision:     "rev",
+				},
+			},
+			`<import manifest="manifest" name="import" path="path" remote="remote" remotebranch="otherbranch" revision="rev"/>`,
+		},
+	}
+	for index, test := range tests {
+		filename := filepath.Join(jirix.Root, fmt.Sprintf("test-%d", index))
+		if err := test.Import.ToFile(jirix, filename); err != nil {
+			t.Errorf("%+v ToFile failed: %v", test.Import, err)
+		}
+		gotBytes, err := jirix.NewSeq().ReadFile(filename)
+		if err != nil {
+			t.Errorf("%+v ReadFile failed: %v", test.Import, err)
+		}
+		if got, want := string(gotBytes), test.XML; got != want {
+			t.Errorf("%+v ToFile GOT\n%v\nWANT\n%v", test.Import, got, want)
+		}
+	}
+}
diff --git a/upgrade.go b/upgrade.go
new file mode 100644
index 0000000..273e83d
--- /dev/null
+++ b/upgrade.go
@@ -0,0 +1,152 @@
+// 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")
+	}
+	var argRemote, argRoot, argPath, argManifest string
+	switch kind := args[0]; kind {
+	case "v23":
+		argRemote = "https://vanadium.googlesource.com/manifest"
+		argRoot, argPath, argManifest = "", "manifest", "v2/default"
+	case "fuchsia":
+		// TODO(toddw): Confirm these choices.
+		argRemote = "https://github.com/effenel/fnl-start.git"
+		argRoot, argPath, argManifest = "", "manifest", "v2/default"
+	default:
+		return jirix.UsageErrorf("unknown upgrade kind %q", kind)
+	}
+	// Initialize manifest from .local_manifest.
+	hasLocalFile := false
+	manifest, err := project.ManifestFromFile(jirix, localFile)
+	switch {
+	case err != nil && !runutil.IsNotExist(err):
+		return err
+	case err == nil:
+		hasLocalFile = true
+		seenOldImport := false
+		var newImports []project.Import
+		for _, oldImport := range manifest.Imports {
+			switch {
+			case oldImport.Remote != "":
+				// This is a new-style remote import, carry it over directly.
+				newImports = append(newImports, oldImport)
+			case !seenOldImport:
+				// This is the first old import, update the manifest name for the remote
+				// import we'll be adding later.
+				argManifest = filepath.Join("v2", oldImport.Name)
+				seenOldImport = true
+			default:
+				// Convert import from name="foo" to file="manifest/v2/foo"
+				manifest.FileImports = append(manifest.FileImports, project.FileImport{
+					File: filepath.Join(argRoot, argPath, "v2", oldImport.Name),
+				})
+			}
+		}
+		manifest.Imports = newImports
+	}
+	if manifest == nil {
+		manifest = &project.Manifest{}
+	}
+	// Add remote import.
+	manifest.Imports = append(manifest.Imports, project.Import{
+		Manifest: argManifest,
+		Root:     argRoot,
+		Project: project.Project{
+			Path:   argPath,
+			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/upgrade_test.go b/upgrade_test.go
new file mode 100644
index 0000000..e80c898
--- /dev/null
+++ b/upgrade_test.go
@@ -0,0 +1,312 @@
+// 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="v2/default" remote="https://vanadium.googlesource.com/manifest"/>
+  </imports>
+</manifest>
+`,
+		},
+		{
+			Args: []string{"v23"},
+			Local: `<manifest>
+  <imports>
+    <import name="default"/>
+  </imports>
+</manifest>
+`,
+			Want: `<manifest>
+  <imports>
+    <import manifest="v2/default" remote="https://vanadium.googlesource.com/manifest"/>
+  </imports>
+</manifest>
+`,
+		},
+		{
+			Args: []string{"v23"},
+			Local: `<manifest>
+  <imports>
+    <import name="private"/>
+  </imports>
+</manifest>
+`,
+			Want: `<manifest>
+  <imports>
+    <import manifest="v2/private" 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="v2/private" remote="https://vanadium.googlesource.com/manifest"/>
+    <fileimport file="manifest/v2/infrastructure"/>
+    <fileimport file="manifest/v2/default"/>
+  </imports>
+</manifest>
+`,
+		},
+		{
+			Args: []string{"v23"},
+			Local: `<manifest>
+  <imports>
+    <import name="default"/>
+    <import name="infrastructure"/>
+    <import name="private"/>
+  </imports>
+</manifest>
+`,
+			Want: `<manifest>
+  <imports>
+    <import manifest="v2/default" remote="https://vanadium.googlesource.com/manifest"/>
+    <fileimport file="manifest/v2/infrastructure"/>
+    <fileimport file="manifest/v2/private"/>
+  </imports>
+</manifest>
+`,
+		},
+		// Test fuchsia upgrades.
+		{
+			Args:   []string{"fuchsia"},
+			Exist:  true,
+			Stderr: `.jiri_manifest already exists`,
+		},
+		{
+			Args: []string{"fuchsia"},
+			Want: `<manifest>
+  <imports>
+    <import manifest="v2/default" remote="https://github.com/effenel/fnl-start.git"/>
+  </imports>
+</manifest>
+`,
+		},
+		{
+			Args: []string{"fuchsia"},
+			Local: `<manifest>
+  <imports>
+    <import name="default"/>
+  </imports>
+</manifest>
+`,
+			Want: `<manifest>
+  <imports>
+    <import manifest="v2/default" remote="https://github.com/effenel/fnl-start.git"/>
+  </imports>
+</manifest>
+`,
+		},
+		{
+			Args: []string{"fuchsia"},
+			Local: `<manifest>
+  <imports>
+    <import name="private"/>
+  </imports>
+</manifest>
+`,
+			Want: `<manifest>
+  <imports>
+    <import manifest="v2/private" remote="https://github.com/effenel/fnl-start.git"/>
+  </imports>
+</manifest>
+`,
+		},
+		{
+			Args: []string{"fuchsia"},
+			Local: `<manifest>
+  <imports>
+    <import name="private"/>
+    <import name="infrastructure"/>
+    <import name="default"/>
+  </imports>
+</manifest>
+`,
+			Want: `<manifest>
+  <imports>
+    <import manifest="v2/private" remote="https://github.com/effenel/fnl-start.git"/>
+    <fileimport file="manifest/v2/infrastructure"/>
+    <fileimport file="manifest/v2/default"/>
+  </imports>
+</manifest>
+`,
+		},
+		{
+			Args: []string{"fuchsia"},
+			Local: `<manifest>
+  <imports>
+    <import name="default"/>
+    <import name="infrastructure"/>
+    <import name="private"/>
+  </imports>
+</manifest>
+`,
+			Want: `<manifest>
+  <imports>
+    <import manifest="v2/default" remote="https://github.com/effenel/fnl-start.git"/>
+    <fileimport file="manifest/v2/infrastructure"/>
+    <fileimport file="manifest/v2/private"/>
+  </imports>
+</manifest>
+`,
+		},
+	}
+	opts := gosh.Opts{Errorf: 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 {
+	sh := gosh.NewShell(opts)
+	defer sh.Cleanup()
+	jiriRoot := sh.MakeTempDir()
+	sh.Pushd(jiriRoot)
+	defer sh.Popd()
+	// 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.Output()
+	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{Errorf: t.Fatalf, Logf: t.Logf})
+	defer sh.Cleanup()
+	jiriRoot := sh.MakeTempDir()
+	sh.Pushd(jiriRoot)
+	defer sh.Popd()
+	jiriTool := sh.BuildGoPkg("v.io/jiri")
+	localData := `<manifest/>`
+	jiriData := `<manifest>
+  <imports>
+    <import manifest="v2/default" 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)
+	}
+}