jiri: Implement new remote imports mechanism.

The existing remote imports mechanism has lots of flaws.  Here's
an attempt at a new implementation that has a chance of working.

MultiPart: 1/2

Change-Id: Ifbe280aa8fb32529e4af1e729890a0edca26ede0
diff --git a/doc.go b/doc.go
index c74bb6b..2c3b843 100644
--- a/doc.go
+++ b/doc.go
@@ -214,31 +214,21 @@
 doesn't already exist, otherwise additional imports are added to the existing
 file.
 
-<manifest> specifies the manifest file to use.
+An <import> element is added to the manifest representing a remote manifest
+import.  The manifest file path is relative to the root directory of the remote
+import repository.
 
-[remote] optionally specifies the remote manifest repository.
-
-If [remote] is not specified, a <fileimport> element is added to the manifest,
-representing a local file import.  The manifest file may be an absolute path, or
-relative to the current working directory.  The resulting path must be a
-subdirectory of $JIRI_ROOT.
-
-If [remote] is specified, an <import> element is added to the manifest,
-representing a remote manifest import.  The remote manifest repository is
-treated similar to regular projects; "jiri update" will update all remote
-manifest repository projects before updating regular projects.  The manifest
-file path is relative to the root directory of the remote import repository.
-
-Example of a local file import:
-  $ jiri import $JIRI_ROOT/path/to/manifest/file
-
-Example of a remote manifest import:
+Example:
   $ jiri import myfile https://foo.com/bar.git
 
 Run "jiri help manifest" for details on manifests.
 
 Usage:
-   jiri import [flags] <manifest> [remote]
+   jiri import [flags] <manifest> <remote>
+
+<manifest> specifies the manifest file to use.
+
+<remote> specifies the remote manifest repository.
 
 The jiri import flags are:
  -name=
@@ -251,15 +241,11 @@
    Write a new .jiri_manifest file with the given specification.  If it already
    exists, the existing content will be ignored and the file will be
    overwritten.
- -path=
-   Path to store the manifest project locally.  Uses "manifest" if unspecified.
  -protocol=git
    The version control protocol used by the remote manifest project.
  -remote-branch=master
    The branch of the remote manifest project to track, without the leading
    "origin/".
- -revision=HEAD
-   The revision of the remote manifest project to reset to during "jiri update".
  -root=
    Root to store the manifest project locally.
 
diff --git a/import.go b/import.go
index fc9ce35..7636f13 100644
--- a/import.go
+++ b/import.go
@@ -5,10 +5,7 @@
 package main
 
 import (
-	"fmt"
 	"os"
-	"path/filepath"
-	"strings"
 
 	"v.io/jiri/jiri"
 	"v.io/jiri/project"
@@ -18,7 +15,7 @@
 
 var (
 	// Flags for configuring project attributes for remote imports.
-	flagImportName, flagImportPath, flagImportProtocol, flagImportRemoteBranch, flagImportRevision, flagImportRoot string
+	flagImportName, flagImportProtocol, flagImportRemoteBranch, flagImportRoot string
 	// Flags for controlling the behavior of the command.
 	flagImportOverwrite bool
 	flagImportOut       string
@@ -26,10 +23,8 @@
 
 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, "remote-branch", "master", `The branch of the remote manifest project to track, without the leading "origin/".`)
-	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.BoolVar(&flagImportOverwrite, "overwrite", false, `Write a new .jiri_manifest file with the given specification.  If it already exists, the existing content will be ignored and the file will be overwritten.`)
@@ -46,34 +41,25 @@
 doesn't already exist, otherwise additional imports are added to the existing
 file.
 
-<manifest> specifies the manifest file to use.
+An <import> element is added to the manifest representing a remote manifest
+import.  The manifest file path is relative to the root directory of the remote
+import repository.
 
-[remote] optionally specifies the remote manifest repository.
-
-If [remote] is not specified, a <fileimport> element is added to the manifest,
-representing a local file import.  The manifest file may be an absolute path, or
-relative to the current working directory.  The resulting path must be a
-subdirectory of $JIRI_ROOT.
-
-If [remote] is specified, an <import> element is added to the manifest,
-representing a remote manifest import.  The remote manifest repository is
-treated similar to regular projects; "jiri update" will update all remote
-manifest repository projects before updating regular projects.  The manifest
-file path is relative to the root directory of the remote import repository.
-
-Example of a local file import:
-  $ jiri import $JIRI_ROOT/path/to/manifest/file
-
-Example of a remote manifest import:
+Example:
   $ jiri import myfile https://foo.com/bar.git
 
 Run "jiri help manifest" for details on manifests.
 `,
-	ArgsName: "<manifest> [remote]",
+	ArgsName: "<manifest> <remote>",
+	ArgsLong: `
+<manifest> specifies the manifest file to use.
+
+<remote> specifies the remote manifest repository.
+`,
 }
 
 func runImport(jirix *jiri.X, args []string) error {
-	if len(args) == 0 || len(args) > 2 {
+	if len(args) != 2 {
 		return jirix.UsageErrorf("wrong number of arguments")
 	}
 	// Initialize manifest.
@@ -88,44 +74,16 @@
 	if manifest == nil {
 		manifest = &project.Manifest{}
 	}
-	// Add the local or remote import.
-	if len(args) == 1 {
-		// FileImport.File is relative to the directory containing the manifest
-		// file; since the .jiri_manifest file is in JIRI_ROOT, that's what it
-		// should be relative to.
-		if _, err := os.Stat(args[0]); err != nil {
-			return err
-		}
-		abs, err := filepath.Abs(args[0])
-		if err != nil {
-			return err
-		}
-		rel, err := filepath.Rel(jirix.Root, abs)
-		if err != nil {
-			return err
-		}
-		if strings.HasPrefix(rel, "..") {
-			return fmt.Errorf("%s is not a subdirectory of JIRI_ROOT %s", abs, jirix.Root)
-		}
-		manifest.FileImports = append(manifest.FileImports, project.FileImport{
-			File: rel,
-		})
-	} else {
-		// There's not much error checking when writing the .jiri_manifest file;
-		// errors will be reported when "jiri update" is run.
-		manifest.Imports = append(manifest.Imports, project.Import{
-			Manifest: args[0],
-			Root:     flagImportRoot,
-			Project: project.Project{
-				Name:         flagImportName,
-				Path:         flagImportPath,
-				Protocol:     flagImportProtocol,
-				Remote:       args[1],
-				RemoteBranch: flagImportRemoteBranch,
-				Revision:     flagImportRevision,
-			},
-		})
-	}
+	// There's not much error checking when writing the .jiri_manifest file;
+	// errors will be reported when "jiri update" is run.
+	manifest.Imports = append(manifest.Imports, project.Import{
+		Manifest:     args[0],
+		Name:         flagImportName,
+		Protocol:     flagImportProtocol,
+		Remote:       args[1],
+		RemoteBranch: flagImportRemoteBranch,
+		Root:         flagImportRoot,
+	})
 	// Write output to stdout or file.
 	outFile := flagImportOut
 	if outFile == "" {
diff --git a/import_test.go b/import_test.go
index d1ae209..7b13b97 100644
--- a/import_test.go
+++ b/import_test.go
@@ -29,58 +29,19 @@
 			Stderr: `wrong number of arguments`,
 		},
 		{
+			Args:   []string{"a"},
+			Stderr: `wrong number of arguments`,
+		},
+		{
 			Args:   []string{"a", "b", "c"},
 			Stderr: `wrong number of arguments`,
 		},
-		// Local file imports, default append behavior
-		{
-			Args: []string{"manfile"},
-			Want: `<manifest>
-  <imports>
-    <fileimport file="manfile"/>
-  </imports>
-</manifest>
-`,
-		},
-		{
-			Args: []string{"./manfile"},
-			Want: `<manifest>
-  <imports>
-    <fileimport file="manfile"/>
-  </imports>
-</manifest>
-`,
-		},
-		{
-			Args: []string{"manfile"},
-			Exist: `<manifest>
-  <imports>
-    <import manifest="bar" remote="https://github.com/orig.git"/>
-  </imports>
-</manifest>
-`,
-			Want: `<manifest>
-  <imports>
-    <import manifest="bar" remote="https://github.com/orig.git"/>
-    <fileimport file="manfile"/>
-  </imports>
-</manifest>
-`,
-		},
-		{
-			Args:   []string{"../manfile"},
-			Stderr: `not a subdirectory of JIRI_ROOT`,
-		},
-		{
-			Args:   []string{"noexist"},
-			Stderr: `no such file`,
-		},
 		// Remote imports, default append behavior
 		{
-			Args: []string{"-name=name", "-path=path", "-remote-branch=remotebranch", "-revision=revision", "-root=root", "foo", "https://github.com/new.git"},
+			Args: []string{"-name=name", "-remote-branch=remotebranch", "-root=root", "foo", "https://github.com/new.git"},
 			Want: `<manifest>
   <imports>
-    <import manifest="foo" root="root" name="name" path="path" remote="https://github.com/new.git" remotebranch="remotebranch" revision="revision"/>
+    <import manifest="foo" name="name" remote="https://github.com/new.git" remotebranch="remotebranch" root="root"/>
   </imports>
 </manifest>
 `,
@@ -129,48 +90,6 @@
 </manifest>
 `,
 		},
-		// Local file imports, explicit overwrite behavior
-		{
-			Args: []string{"-overwrite", "manfile"},
-			Want: `<manifest>
-  <imports>
-    <fileimport file="manfile"/>
-  </imports>
-</manifest>
-`,
-		},
-		{
-			Args: []string{"-overwrite", "./manfile"},
-			Want: `<manifest>
-  <imports>
-    <fileimport file="manfile"/>
-  </imports>
-</manifest>
-`,
-		},
-		{
-			Args: []string{"-overwrite", "manfile"},
-			Exist: `<manifest>
-  <imports>
-    <import manifest="bar" remote="https://github.com/orig.git"/>
-  </imports>
-</manifest>
-`,
-			Want: `<manifest>
-  <imports>
-    <fileimport file="manfile"/>
-  </imports>
-</manifest>
-`,
-		},
-		{
-			Args:   []string{"-overwrite", "../manfile"},
-			Stderr: `not a subdirectory of JIRI_ROOT`,
-		},
-		{
-			Args:   []string{"-overwrite", "noexist"},
-			Stderr: `no such file`,
-		},
 		// Remote imports, explicit overwrite behavior
 		{
 			Args: []string{"-overwrite", "foo", "https://github.com/new.git"},
diff --git a/jiri/x.go b/jiri/x.go
index 6a574b3..c67a452 100644
--- a/jiri/x.go
+++ b/jiri/x.go
@@ -144,6 +144,9 @@
 
 // ResolveManifestPath resolves the given manifest name to an absolute path in
 // the local filesystem.
+//
+// TODO(toddw): Remove this once the transition to new manifests is done.  In
+// the new world, we always start with the JiriManifestFile.
 func (x *X) ResolveManifestPath(name string) (string, error) {
 	if x.UsingOldManifests() {
 		return x.resolveManifestPathDeprecated(name)
diff --git a/profiles/profilesreader/reader.go b/profiles/profilesreader/reader.go
index eff4533..09bea9c 100644
--- a/profiles/profilesreader/reader.go
+++ b/profiles/profilesreader/reader.go
@@ -104,7 +104,6 @@
 	jirix        *jiri.X
 	config       *util.Config
 	projects     project.Projects
-	tools        project.Tools
 	pdb          *profiles.DB
 }
 
@@ -117,7 +116,7 @@
 	if err != nil {
 		return nil, err
 	}
-	projects, tools, err := project.ReadJiriManifest(jirix)
+	projects, _, err := project.LoadManifest(jirix)
 	if err != nil {
 		return nil, err
 	}
@@ -131,7 +130,6 @@
 		jirix:        jirix,
 		config:       config,
 		projects:     projects,
-		tools:        tools,
 		profilesMode: bool(profilesMode),
 		pdb:          pdb,
 	}
diff --git a/project/.api b/project/.api
index f475c27..7f0d015 100644
--- a/project/.api
+++ b/project/.api
@@ -9,6 +9,7 @@
 pkg project, func DataDirPath(*jiri.X, string) (string, error)
 pkg project, func GetProjectStates(*jiri.X, bool) (map[ProjectKey]*ProjectState, error)
 pkg project, func InstallTools(*jiri.X, string) error
+pkg project, func LoadManifest(*jiri.X) (Projects, Tools, error)
 pkg project, func LocalProjects(*jiri.X, ScanMode) (Projects, error)
 pkg project, func MakeProjectKey(string, string) ProjectKey
 pkg project, func ManifestFromBytes([]byte) (*Manifest, error)
@@ -17,13 +18,11 @@
 pkg project, func PollProjects(*jiri.X, map[string]struct{}) (Update, error)
 pkg project, func ProjectAtPath(*jiri.X, string) (Project, error)
 pkg project, func ProjectFromFile(*jiri.X, string) (*Project, error)
-pkg project, func ReadJiriManifest(*jiri.X) (Projects, Tools, error)
 pkg project, func TransitionBinDir(*jiri.X) error
 pkg project, func UpdateUniverse(*jiri.X, bool) error
+pkg project, method (*Import) ProjectKey() ProjectKey
 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
@@ -39,18 +38,21 @@
 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 Import struct
 pkg project, type Import struct, Manifest string
+pkg project, type Import struct, Name string
+pkg project, type Import struct, Protocol string
+pkg project, type Import struct, Remote string
+pkg project, type Import struct, RemoteBranch 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 LocalImport struct
+pkg project, type LocalImport struct, File string
+pkg project, type LocalImport struct, XMLName struct{}
 pkg project, type Manifest struct
-pkg project, type Manifest struct, FileImports []FileImport
 pkg project, type Manifest struct, Imports []Import
 pkg project, type Manifest struct, Label string
+pkg project, type Manifest struct, LocalImports []LocalImport
 pkg project, type Manifest struct, Projects []Project
 pkg project, type Manifest struct, Tools []Tool
 pkg project, type Manifest struct, XMLName struct{}
diff --git a/project/paths.go b/project/paths.go
index d34c41f..e21e9bc 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 := ReadJiriManifest(jirix)
+	projects, tools, err := LoadManifest(jirix)
 	if err != nil {
 		return "", err
 	}
diff --git a/project/project.go b/project/project.go
index 7b81ad4..3d52109 100644
--- a/project/project.go
+++ b/project/project.go
@@ -8,6 +8,7 @@
 	"bytes"
 	"encoding/xml"
 	"fmt"
+	"hash/fnv"
 	"io/ioutil"
 	"net/url"
 	"os"
@@ -40,12 +41,12 @@
 
 // Manifest represents a setting used for updating the universe.
 type Manifest struct {
-	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"`
+	Imports      []Import      `xml:"imports>import"`
+	LocalImports []LocalImport `xml:"imports>localimport"`
+	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
@@ -81,11 +82,11 @@
 	emptyProjectsBytes = []byte("\n  <projects></projects>\n")
 	emptyToolsBytes    = []byte("\n  <tools></tools>\n")
 
-	endElemBytes       = []byte("/>\n")
-	endImportBytes     = []byte("></import>\n")
-	endFileImportBytes = []byte("></fileimport>\n")
-	endProjectBytes    = []byte("></project>\n")
-	endToolBytes       = []byte("></tool>\n")
+	endElemBytes        = []byte("/>\n")
+	endImportBytes      = []byte("></import>\n")
+	endLocalImportBytes = []byte("></localimport>\n")
+	endProjectBytes     = []byte("></project>\n")
+	endToolBytes        = []byte("></tool>\n")
 
 	endImportSoloBytes  = []byte("></import>")
 	endProjectSoloBytes = []byte("></project>")
@@ -97,7 +98,7 @@
 	x := new(Manifest)
 	x.Label = m.Label
 	x.Imports = append([]Import(nil), m.Imports...)
-	x.FileImports = append([]FileImport(nil), m.FileImports...)
+	x.LocalImports = append([]LocalImport(nil), m.LocalImports...)
 	x.Projects = append([]Project(nil), m.Projects...)
 	x.Tools = append([]Tool(nil), m.Tools...)
 	return x
@@ -119,7 +120,7 @@
 	data = bytes.Replace(data, emptyProjectsBytes, newlineBytes, -1)
 	data = bytes.Replace(data, emptyToolsBytes, newlineBytes, -1)
 	data = bytes.Replace(data, endImportBytes, endElemBytes, -1)
-	data = bytes.Replace(data, endFileImportBytes, endElemBytes, -1)
+	data = bytes.Replace(data, endLocalImportBytes, endElemBytes, -1)
 	data = bytes.Replace(data, endProjectBytes, endElemBytes, -1)
 	data = bytes.Replace(data, endToolBytes, endElemBytes, -1)
 	if !bytes.HasSuffix(data, newlineBytes) {
@@ -153,8 +154,8 @@
 			return err
 		}
 	}
-	for index := range m.FileImports {
-		if err := m.FileImports[index].validate(); err != nil {
+	for index := range m.LocalImports {
+		if err := m.LocalImports[index].validate(); err != nil {
 			return err
 		}
 	}
@@ -177,8 +178,8 @@
 			return err
 		}
 	}
-	for index := range m.FileImports {
-		if err := m.FileImports[index].validate(); err != nil {
+	for index := range m.LocalImports {
+		if err := m.LocalImports[index].validate(); err != nil {
 			return err
 		}
 	}
@@ -199,36 +200,37 @@
 type Import struct {
 	// 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
+	// Name is the name of the remote manifest project, used to determine the
+	// project key.
+	//
+	// If Remote and Manifest are empty, it is the old-style name of the manifest
+	// to import, similar to localimport. This is deprecated behavior, and will be
+	// removed.
+	//
+	// TODO(toddw): Remove the old behavior when the transition to new-style
+	// manifests is complete.
+	Name string `xml:"name,attr,omitempty"`
+	// Protocol is the version control protocol used by the remote manifest
+	// project. If not set, "git" is used as the default.
+	Protocol string `xml:"protocol,attr,omitempty"`
+	// Remote is the remote manifest project to import.
+	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". If not set, "master" is used as the default.
+	RemoteBranch string `xml:"remotebranch,attr,omitempty"`
+	// Root path, prepended to all project paths specified in the manifest file.
+	Root    string   `xml:"root,attr,omitempty"`
 	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 i.Protocol == "" {
+			i.Protocol = "git"
 		}
-		if err := i.Project.fillDefaults(); err != nil {
-			return err
+		if i.RemoteBranch == "" {
+			i.RemoteBranch = "master"
 		}
 	}
 	return i.validate()
@@ -236,11 +238,11 @@
 
 func (i *Import) unfillDefaults() error {
 	if i.Remote != "" {
-		if i.Path == "manifest" {
-			i.Path = ""
+		if i.Protocol == "git" {
+			i.Protocol = ""
 		}
-		if err := i.Project.unfillDefaults(); err != nil {
-			return err
+		if i.RemoteBranch == "master" {
+			i.RemoteBranch = ""
 		}
 	}
 	return i.validate()
@@ -268,31 +270,56 @@
 	return nil
 }
 
-// remoteKey returns a key based on the remote and manifest, used for
+func (i *Import) toProject(path string) (Project, error) {
+	p := Project{
+		Name:         i.Name,
+		Path:         path,
+		Protocol:     i.Protocol,
+		Remote:       i.Remote,
+		RemoteBranch: i.RemoteBranch,
+	}
+	err := p.fillDefaults()
+	return p, err
+}
+
+// ProjectKey returns the unique ProjectKey for the imported project.
+func (i *Import) ProjectKey() ProjectKey {
+	return MakeProjectKey(i.Name, i.Remote)
+}
+
+// projectKeyFileName returns a file name based on the ProjectKey.
+func (i *Import) projectKeyFileName() string {
+	// TODO(toddw): Disallow weird characters from project names.
+	hash := fnv.New64a()
+	hash.Write([]byte(i.ProjectKey()))
+	return fmt.Sprintf("%s_%x", i.Name, hash.Sum64())
+}
+
+// cycleKey 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 {
+func (i *Import) cycleKey() string {
 	if i.Remote == "" {
 		return ""
 	}
-	// We don't join the remote and manifest with a slash, since that might not be
-	// unique.  E.g.
+	// We don't join the remote and manifest with a slash or any other url-safe
+	// character, 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 {
+// LocalImport represents a local manifest import.
+type LocalImport struct {
 	// Manifest file to import from.
 	File    string   `xml:"file,attr,omitempty"`
-	XMLName struct{} `xml:"fileimport"`
+	XMLName struct{} `xml:"localimport"`
 }
 
-func (i *FileImport) validate() error {
+func (i *LocalImport) validate() error {
 	if i.File == "" {
-		return fmt.Errorf("bad fileimport: must specify file: %+v", *i)
+		return fmt.Errorf("bad localimport: must specify file: %+v", *i)
 	}
 	return nil
 }
@@ -398,10 +425,13 @@
 	}
 	// Same logic as Manifest.ToBytes, to make the output more compact.
 	data = bytes.Replace(data, endProjectSoloBytes, endElemSoloBytes, -1)
+	if !bytes.HasSuffix(data, newlineBytes) {
+		data = append(data, '\n')
+	}
 	return safeWriteFile(jirix, filename, data)
 }
 
-// Key returns a unique ProjectKey for the project.
+// Key returns the unique ProjectKey for the project.
 func (p Project) Key() ProjectKey {
 	return MakeProjectKey(p.Name, p.Remote)
 }
@@ -563,7 +593,7 @@
 	}
 
 	// Add all tools from the current manifest to the snapshot manifest.
-	_, tools, err := ReadJiriManifest(jirix)
+	_, tools, err := LoadManifest(jirix)
 	if err != nil {
 		return err
 	}
@@ -642,25 +672,29 @@
 	jirix.TimerPush("local projects")
 	defer jirix.TimerPop()
 
-	latestUpdateSnapshot := jirix.UpdateHistoryLatestLink()
-	latestUpdateSnapshotExists, err := jirix.NewSeq().IsFile(latestUpdateSnapshot)
+	latestSnapshot := jirix.UpdateHistoryLatestLink()
+	latestSnapshotExists, err := jirix.NewSeq().IsFile(latestSnapshot)
 	if err != nil {
 		return nil, err
 	}
-	if scanMode == FastScan && latestUpdateSnapshotExists {
-		// Fast path:  Full scan was not requested, and we have a snapshot
-		// containing the latest update.  Check that the projects listed in the
-		// snapshot exist locally.  If not, then fall back on the slow path.
-		manifestProjects, _, err := readManifestFile(jirix, latestUpdateSnapshot)
+	if scanMode == FastScan && latestSnapshotExists {
+		// Fast path: Full scan was not requested, and we have a snapshot containing
+		// the latest update.  Check that the projects listed in the snapshot exist
+		// locally.  If not, then fall back on the slow path.
+		//
+		// An error will be returned if the snapshot contains remote imports, since
+		// that would cause an infinite loop; we'd need local projects, in order to
+		// load the snapshot, in order to determine the local projects.
+		snapshotProjects, _, err := loadManifestFile(jirix, latestSnapshot, nil)
 		if err != nil {
 			return nil, err
 		}
-		projectsExist, err := projectsExistLocally(jirix, manifestProjects)
+		projectsExist, err := projectsExistLocally(jirix, snapshotProjects)
 		if err != nil {
 			return nil, err
 		}
 		if projectsExist {
-			return setProjectRevisions(jirix, manifestProjects)
+			return setProjectRevisions(jirix, snapshotProjects)
 		}
 	}
 
@@ -715,7 +749,7 @@
 	if err != nil {
 		return nil, err
 	}
-	remoteProjects, _, err := ReadJiriManifest(jirix)
+	remoteProjects, _, err := LoadManifest(jirix)
 	if err != nil {
 		return nil, err
 	}
@@ -776,25 +810,43 @@
 	return update, nil
 }
 
-// ReadJiriManifest reads and parses the .jiri_manifest file.
-func ReadJiriManifest(jirix *jiri.X) (Projects, Tools, error) {
-	file, err := jirix.ResolveManifestPath(jirix.Manifest())
+// LoadManifest loads the manifest, starting with the .jiri_manifest file,
+// resolving remote and local imports.  Returns the projects and tools specified
+// by the manifest.
+//
+// If the user is still using old-style manifests, it uses the old
+// ResolveManifestPath logic to determine the initial manifest file, since the
+// .jiri_manifest doesn't exist.
+func LoadManifest(jirix *jiri.X) (Projects, Tools, error) {
+	jirix.TimerPush("load manifest")
+	defer jirix.TimerPop()
+	var (
+		file          string
+		localProjects Projects
+		err           error
+	)
+	// TODO(toddw): Remove old manifest logic when the transition is complete.
+	if jirix.UsingOldManifests() {
+		file, err = jirix.ResolveManifestPath(jirix.Manifest())
+	} else {
+		file = jirix.JiriManifestFile()
+		localProjects, err = LocalProjects(jirix, FastScan)
+	}
 	if err != nil {
 		return nil, nil, err
 	}
-	return readManifestFile(jirix, file)
+	return loadManifestFile(jirix, file, localProjects)
 }
 
-// readManifestFile reads and parses the manifest with the given filename.
-func readManifestFile(jirix *jiri.X, file string) (Projects, Tools, error) {
-	jirix.TimerPush("read manifest")
-	defer jirix.TimerPop()
-	var imp importer
-	projects, tools := Projects{}, Tools{}
-	if err := imp.Load(jirix, jirix.Root, file, "", projects, tools); err != nil {
+// loadManifestFile loads the manifest starting with the given file, resolving
+// remote and local imports.  Local projects are used to resolve remote imports;
+// if nil, encountering any remote import will result in an error.
+func loadManifestFile(jirix *jiri.X, file string, localProjects Projects) (Projects, Tools, error) {
+	ld := newManifestLoader(localProjects, false)
+	if err := ld.Load(jirix, "", file, ""); err != nil {
 		return nil, nil, err
 	}
-	return projects, tools, nil
+	return ld.Projects, ld.Tools, nil
 }
 
 // getManifestRemote returns the remote url of the origin from the manifest
@@ -811,76 +863,84 @@
 		}, "get manifest origin").Done()
 }
 
-func updateManifestProjects(jirix *jiri.X) error {
-	jirix.TimerPush("update manifest")
+func loadUpdatedManifest(jirix *jiri.X, localProjects Projects) (Projects, Tools, string, error) {
+	jirix.TimerPush("load updated manifest")
 	defer jirix.TimerPop()
 	if jirix.UsingOldManifests() {
-		return updateManifestProjectsDeprecated(jirix)
+		projects, tools, err := loadUpdatedManifestDeprecated(jirix)
+		return projects, tools, "", err
 	}
-	// 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
+	ld := newManifestLoader(localProjects, true)
+	if err := ld.Load(jirix, "", jirix.JiriManifestFile(), ""); err != nil {
+		return nil, nil, ld.TmpDir, err
 	}
-	file, err := jirix.ResolveManifestPath(jirix.Manifest())
-	if err != nil {
-		return err
-	}
-	var imp importer
-	return imp.Update(jirix, jirix.Root, file, "", localProjects)
+	return ld.Projects, ld.Tools, ld.TmpDir, nil
 }
 
-func updateManifestProjectsDeprecated(jirix *jiri.X) error {
+// TODO(toddw): Remove this logic when the transition to new manifests is done.
+func loadUpdatedManifestDeprecated(jirix *jiri.X) (Projects, Tools, error) {
 	manifestPath := filepath.Join(jirix.Root, ".manifest")
 	manifestRemote, err := getManifestRemote(jirix, manifestPath)
 	if err != nil {
-		return err
+		return nil, nil, err
 	}
 	project := Project{
-		Path:         manifestPath,
-		Protocol:     "git",
-		Remote:       manifestRemote,
-		Revision:     "HEAD",
-		RemoteBranch: "master",
+		Path:   manifestPath,
+		Remote: manifestRemote,
 	}
-	return resetProject(jirix, project)
+	if err := project.fillDefaults(); err != nil {
+		return nil, nil, err
+	}
+	if err := syncProjectMaster(jirix, project); err != nil {
+		return nil, nil, err
+	}
+	file, err := jirix.ResolveManifestPath(jirix.Manifest())
+	if err != nil {
+		return nil, nil, err
+	}
+	return loadManifestFile(jirix, file, nil)
 }
 
-// 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
-// longer exist remotely should be removed.
+// UpdateUniverse updates all local projects and tools to match the remote
+// counterparts identified in the manifest. Optionally, the 'gc' flag can be
+// used to indicate that local projects that no longer exist remotely should be
+// removed.
 func UpdateUniverse(jirix *jiri.X, gc bool) (e error) {
 	jirix.TimerPush("update universe")
 	defer jirix.TimerPop()
-	// 0. Update all manifest projects to match their remote counterparts, and
-	// read the manifest file.
-	if err := updateManifestProjects(jirix); err != nil {
-		return err
+	// 0. Load the manifest, updating all manifest projects to match their remote
+	// counterparts.
+	scanMode := FastScan
+	if gc {
+		scanMode = FullScan
 	}
-	remoteProjects, remoteTools, err := ReadJiriManifest(jirix)
+	localProjects, err := LocalProjects(jirix, scanMode)
 	if err != nil {
 		return err
 	}
 	s := jirix.NewSeq()
+	remoteProjects, remoteTools, tmpLoadDir, err := loadUpdatedManifest(jirix, localProjects)
+	if tmpLoadDir != "" {
+		defer collect.Error(func() error { return s.RemoveAll(tmpLoadDir).Done() }, &e)
+	}
+	if err != nil {
+		return err
+	}
 	// 1. Update all local projects to match their remote counterparts.
-	if err := updateProjects(jirix, remoteProjects, gc); err != nil {
+	if err := updateProjects(jirix, localProjects, remoteProjects, gc); err != nil {
 		return err
 	}
 	// 2. Build all tools in a temporary directory.
-	tmpDir, err := s.TempDir("", "tmp-jiri-tools-build")
+	tmpToolsDir, err := s.TempDir("", "tmp-jiri-tools-build")
 	if err != nil {
 		return fmt.Errorf("TempDir() failed: %v", err)
 	}
-	defer collect.Error(func() error { return s.RemoveAll(tmpDir).Done() }, &e)
-	if err := buildToolsFromMaster(jirix, remoteTools, tmpDir); err != nil {
+	defer collect.Error(func() error { return s.RemoveAll(tmpToolsDir).Done() }, &e)
+	if err := buildToolsFromMaster(jirix, remoteProjects, remoteTools, tmpToolsDir); err != nil {
 		return err
 	}
 	// 3. Install the tools into $JIRI_ROOT/.jiri_root/bin.
-	return InstallTools(jirix, tmpDir)
+	return InstallTools(jirix, tmpToolsDir)
 }
 
 // ApplyToLocalMaster applies an operation expressed as the given function to
@@ -1003,13 +1063,8 @@
 // available in the local master branch of the tools repository. Notably, this
 // function does not perform any version control operation on the master
 // branch.
-func buildToolsFromMaster(jirix *jiri.X, tools Tools, outputDir string) error {
-	localProjects, err := LocalProjects(jirix, FastScan)
-	if err != nil {
-		return err
-	}
+func buildToolsFromMaster(jirix *jiri.X, projects Projects, tools Tools, outputDir string) error {
 	failed := false
-
 	toolsToBuild, toolProjects := Tools{}, Projects{}
 	toolNames := []string{} // Used for logging purposes.
 	for _, tool := range tools {
@@ -1020,7 +1075,7 @@
 		if tool.Package == "" {
 			continue
 		}
-		project, err := localProjects.FindUnique(tool.Project)
+		project, err := projects.FindUnique(tool.Project)
 		if err != nil {
 			return err
 		}
@@ -1263,213 +1318,263 @@
 	return nil
 }
 
-// resetProject advances the local master branch of the given
-// project, which is expected to exist locally at project.Path.
-func resetProject(jirix *jiri.X, project Project) error {
-	fn := func() error {
-		switch project.Protocol {
-		case "git":
-			if project.Remote == "" {
-				return fmt.Errorf("project %v does not have a remote", project.Name)
-			}
-			if err := gitutil.New(jirix.NewSeq()).SetRemoteUrl("origin", project.Remote); err != nil {
-				return err
-			}
-			if err := gitutil.New(jirix.NewSeq()).Fetch("origin"); err != nil {
-				return err
-			}
-
-			// Having a specific revision trumps everything else - once fetched,
-			// always reset to that revision.
-			if project.Revision != "" && project.Revision != "HEAD" {
-				return gitutil.New(jirix.NewSeq()).Reset(project.Revision)
-			}
-
-			// If no revision, reset to the configured remote branch, or master
-			// if no remote branch.
-			remoteBranch := project.RemoteBranch
-			if remoteBranch == "" {
-				remoteBranch = "master"
-			}
-			return gitutil.New(jirix.NewSeq()).Reset("origin/" + remoteBranch)
-		default:
-			return UnsupportedProtocolErr(project.Protocol)
+// fetchProject fetches from the project remote.
+func fetchProject(jirix *jiri.X, project Project) error {
+	switch project.Protocol {
+	case "git":
+		if project.Remote == "" {
+			return fmt.Errorf("project %q does not have a remote", project.Name)
 		}
+		if err := gitutil.New(jirix.NewSeq()).SetRemoteUrl("origin", project.Remote); err != nil {
+			return err
+		}
+		return gitutil.New(jirix.NewSeq()).Fetch("origin")
+	default:
+		return UnsupportedProtocolErr(project.Protocol)
 	}
-	return ApplyToLocalMaster(jirix, Projects{project.Key(): project}, fn)
 }
 
-// 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
+// resetProjectCurrentBranch resets the current branch to the revision and
+// branch specified on the project.
+func resetProjectCurrentBranch(jirix *jiri.X, project Project) error {
+	if err := project.fillDefaults(); err != nil {
+		return err
+	}
+	switch project.Protocol {
+	case "git":
+		// Having a specific revision trumps everything else.
+		if project.Revision != "HEAD" {
+			return gitutil.New(jirix.NewSeq()).Reset(project.Revision)
+		}
+		// If no revision, reset to the configured remote branch, or master
+		// if no remote branch.
+		return gitutil.New(jirix.NewSeq()).Reset("origin/" + project.RemoteBranch)
+	default:
+		return UnsupportedProtocolErr(project.Protocol)
+	}
+}
+
+// syncProjectMaster fetches from the project remote and resets the local master
+// branch to the revision and branch specified on the project.
+func syncProjectMaster(jirix *jiri.X, project Project) error {
+	return ApplyToLocalMaster(jirix, Projects{project.Key(): project}, func() error {
+		if err := fetchProject(jirix, project); err != nil {
+			return err
+		}
+		return resetProjectCurrentBranch(jirix, project)
+	})
+}
+
+// newManifestLoader returns a new manifest loader.  The localProjects are used
+// to resolve remote imports; if nil, encountering any remote import will result
+// in an error.  If update is true, remote manifest import projects that don't
+// exist locally are cloned under TmpDir, and inserted into localProjects.
+//
+// If update is true, remote changes to manifest projects will be fetched, and
+// manifest projects that don't exist locally will be created in temporary
+// directories, and added to localProjects.
+func newManifestLoader(localProjects Projects, update bool) *loader {
+	return &loader{
+		Projects:      make(Projects),
+		Tools:         make(Tools),
+		localProjects: localProjects,
+		update:        update,
+	}
+}
+
+type loader struct {
+	Projects      Projects
+	Tools         Tools
+	TmpDir        string
+	localProjects Projects
+	update        bool
+	cycleStack    []cycleInfo
 }
 
 type cycleInfo struct {
 	file, key string
 }
 
-// importNoCycles checks for cycles in imports.  There are two types of cycles:
+// loadNoCycles 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"/>
+//       <localimport file="B"/>      <localimport 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>
+//     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
+// running the recursive read 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.
+// there's a cycle.  Note that we know the file for both local and remote
+// imports, but we only know the key for remote imports; the key for local
+// imports 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))
+// A more complex case would involve a combination of local 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 (ld *loader) loadNoCycles(jirix *jiri.X, root, file, cycleKey string) error {
+	info := cycleInfo{file, cycleKey}
+	for _, c := range ld.cycleStack {
+		switch {
+		case file == c.file:
+			return fmt.Errorf("import cycle detected in local manifest files: %q", append(ld.cycleStack, info))
+		case cycleKey == c.key && cycleKey != "":
+			return fmt.Errorf("import cycle detected in remote manifest imports: %q", append(ld.cycleStack, info))
 		}
 	}
-	imp.cycleStack = append(imp.cycleStack, info)
-	if err := fn(); err != nil {
+	ld.cycleStack = append(ld.cycleStack, info)
+	if err := ld.load(jirix, root, file); err != nil {
 		return err
 	}
-	imp.cycleStack = imp.cycleStack[:len(imp.cycleStack)-1]
+	ld.cycleStack = ld.cycleStack[:len(ld.cycleStack)-1]
 	return nil
 }
 
-func (imp *importer) Load(jirix *jiri.X, root, file, key string, projects Projects, tools Tools) error {
-	return imp.importNoCycles(file, key, func() error {
-		return imp.load(jirix, root, file, projects, tools)
-	})
+// shortFileName returns the relative path if file is relative to root,
+// otherwise returns the file name unchanged.
+func shortFileName(root, file string) string {
+	if p := root + string(filepath.Separator); strings.HasPrefix(file, p) {
+		return file[len(p):]
+	}
+	return file
 }
 
-func (imp *importer) load(jirix *jiri.X, root, file string, projects Projects, tools Tools) error {
+func (ld *loader) Load(jirix *jiri.X, root, file, cycleKey string) error {
+	jirix.TimerPush("load " + shortFileName(jirix.Root, file))
+	defer jirix.TimerPop()
+	return ld.loadNoCycles(jirix, root, file, cycleKey)
+}
+
+func (ld *loader) load(jirix *jiri.X, root, file string) error {
 	m, err := ManifestFromFile(jirix, file)
 	if err != nil {
 		return err
 	}
-	// Process all imports.
-	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
-			}
-		}
-		if err := imp.Load(jirix, newRoot, newFile, _import.remoteKey(), projects, tools); err != nil {
-			return err
-		}
-	}
-	// Process all file imports.
-	for _, fileImport := range m.FileImports {
-		newFile := filepath.Join(filepath.Dir(file), fileImport.File)
-		if err := imp.Load(jirix, root, newFile, "", projects, tools); err != nil {
-			return err
-		}
-	}
-	// Process all projects.
-	for _, project := range m.Projects {
-		project.Path = filepath.Join(root, project.Path)
-		projects[project.Key()] = project
-	}
-	// Process all tools.
-	for _, tool := range m.Tools {
-		tools[tool.Name] = tool
-	}
-	return nil
-}
-
-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.
+	// Process remote imports.
 	for _, remote := range m.Imports {
 		if remote.Remote == "" {
-			// Old-style local imports handled in loop below.
+			// Old-style named 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
+		nextRoot, nextFile := filepath.Join(root, remote.Root), ""
+		key := remote.ProjectKey()
+		p, ok := ld.localProjects[key]
+		if !ok {
+			if !ld.update {
+				return fmt.Errorf("can't resolve remote import: project %q not found locally", key)
+			}
+			// The remote manifest project doesn't exist locally.  Clone it into a
+			// temp directory, and add it to ld.localProjects.
+			if ld.TmpDir == "" {
+				if ld.TmpDir, err = jirix.NewSeq().TempDir("", "jiri-load"); err != nil {
+					return fmt.Errorf("TempDir() failed: %v", err)
+				}
+			}
+			path := filepath.Join(ld.TmpDir, remote.projectKeyFileName())
+			if p, err = remote.toProject(path); err != nil {
+				return err
+			}
+			if err := jirix.NewSeq().MkdirAll(path, 0755).Done(); err != nil {
+				return err
+			}
+			if err := gitutil.New(jirix.NewSeq()).Clone(p.Remote, path); err != nil {
+				return err
+			}
+			ld.localProjects[key] = p
 		}
-		// Since &remote.Project is never nil, we'll never produce a delete op.
-		op := computeOp(localProject, &remote.Project, false, newRoot)
-		if err := op.Test(jirix, newFsUpdates()); err != nil {
-			return err
+		// Reset the project to its specified branch and load the next file.  Note
+		// that we call load() recursively, so multiple files may be loaded by
+		// resetAndLoad.
+		if strings.HasPrefix(p.Path, ld.TmpDir) {
+			nextFile = filepath.Join(p.Path, remote.Manifest)
+		} else {
+			nextFile = filepath.Join(jirix.Root, nextRoot, p.Path, remote.Manifest)
 		}
-		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 {
+		if err := ld.resetAndLoad(jirix, nextRoot, nextFile, remote.cycleKey(), p); err != nil {
 			return err
 		}
 	}
-	// Process all old-style local imports.
-	for _, local := range m.Imports {
-		if local.Remote != "" {
+	// Process old-style named imports.
+	//
+	// TODO(toddw): Remove this logic when the manifest transition is done.
+	for _, named := range m.Imports {
+		if named.Remote != "" {
 			// New-style remote imports handled in loop above.
 			continue
 		}
-		newFile, err := jirix.ResolveManifestPath(local.Name)
+		nextFile, err := jirix.ResolveManifestPath(named.Name)
 		if err != nil {
 			return err
 		}
-		if err := imp.Update(jirix, root, newFile, "", localProjects); err != nil {
+		if err := ld.Load(jirix, root, nextFile, ""); 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 {
+	// Process local imports.
+	for _, local := range m.LocalImports {
+		// TODO(toddw): Add our invariant check that the file is in the same
+		// repository as the current remote import repository.
+		nextFile := filepath.Join(filepath.Dir(file), local.File)
+		if err := ld.Load(jirix, root, nextFile, ""); err != nil {
 			return err
 		}
 	}
+	// Collect projects.
+	for _, project := range m.Projects {
+		project.Path = filepath.Join(jirix.Root, root, project.Path)
+		key := project.Key()
+		if dup, ok := ld.Projects[key]; ok && dup != project {
+			// TODO(toddw): Tell the user the other conflicting file.
+			return fmt.Errorf("duplicate project %q found in %v", key, shortFileName(jirix.Root, file))
+		}
+		ld.Projects[key] = project
+	}
+	// Collect tools.
+	for _, tool := range m.Tools {
+		name := tool.Name
+		if dup, ok := ld.Tools[name]; ok && dup != tool {
+			// TODO(toddw): Tell the user the other conflicting file.
+			return fmt.Errorf("duplicate tool %q found in %v", name, shortFileName(jirix.Root, file))
+		}
+		ld.Tools[name] = tool
+	}
 	return nil
 }
 
+func (ld *loader) resetAndLoad(jirix *jiri.X, root, file, cycleKey string, project Project) (e error) {
+	// Change to the project.Path directory, and revert when done.
+	pushd := jirix.NewSeq().Pushd(project.Path)
+	defer collect.Error(pushd.Done, &e)
+	// Reset the local master branch to what's specified on the project.  We only
+	// fetch on updates; non-updates just perform the reset.
+	//
+	// TODO(toddw): Support "jiri update -local=p1,p2" by simply calling ld.Load
+	// for the given projects, rather than ApplyToLocalMaster(fetch+reset+load).
+	return ApplyToLocalMaster(jirix, Projects{project.Key(): project}, func() error {
+		if ld.update {
+			if err := fetchProject(jirix, project); err != nil {
+				return err
+			}
+		}
+		if err := resetProjectCurrentBranch(jirix, project); err != nil {
+			return err
+		}
+		return ld.Load(jirix, root, file, cycleKey)
+	})
+}
+
 // reportNonMaster checks if the given project is on master branch and
 // if not, reports this fact along with information on how to update it.
 func reportNonMaster(jirix *jiri.X, project Project) (e error) {
@@ -1559,18 +1664,10 @@
 	}
 }
 
-func updateProjects(jirix *jiri.X, remoteProjects Projects, gc bool) error {
+func updateProjects(jirix *jiri.X, localProjects, remoteProjects Projects, gc bool) error {
 	jirix.TimerPush("update projects")
 	defer jirix.TimerPop()
 
-	scanMode := FastScan
-	if gc {
-		scanMode = FullScan
-	}
-	localProjects, err := LocalProjects(jirix, scanMode)
-	if err != nil {
-		return err
-	}
 	getRemoteHeadRevisions(jirix, remoteProjects)
 	ops := computeOperations(localProjects, remoteProjects, gc, "")
 	updates := newFsUpdates()
@@ -1595,15 +1692,15 @@
 		return cmdline.ErrExitCode(2)
 	}
 
-	// Run all RunHook scripts.
+	// Run all RunHook scripts, apply githooks, and write the current manifest.
+	// TODO(toddw): What's the significance of exit code 2?
 	if !runHooks(jirix, ops) {
 		return cmdline.ErrExitCode(2)
 	}
-
-	if err := writeCurrentManifest(jirix, manifest); err != nil {
-		return err
+	if err := applyGitHooks(jirix, ops); err != nil {
+		return cmdline.ErrExitCode(2)
 	}
-	return nil
+	return writeCurrentManifest(jirix, manifest)
 }
 
 // runHooks runs all hooks for the given operations.  It returns true iff all
@@ -1616,7 +1713,7 @@
 		if op.Project().RunHook == "" {
 			continue
 		}
-		if op.Kind() != "create" && op.Kind() != "update" && op.Kind() != "move" {
+		if op.Kind() != "create" && op.Kind() != "move" && op.Kind() != "update" {
 			continue
 		}
 		s := jirix.NewSeq()
@@ -1633,6 +1730,63 @@
 	return ok
 }
 
+func applyGitHooks(jirix *jiri.X, ops []operation) error {
+	jirix.TimerPush("apply githooks")
+	defer jirix.TimerPop()
+	s := jirix.NewSeq()
+	for _, op := range ops {
+		if op.Kind() == "create" || op.Kind() == "move" {
+			// Apply exclusion for /.jiri/. Ideally we'd only write this file on
+			// create, but the remote manifest import is move from the temp directory
+			// into the final spot, so we need this to apply to both.
+			//
+			// TODO(toddw): Find a better way to do this.
+			excludeDir := filepath.Join(op.Project().Path, ".git", "info")
+			excludeFile := filepath.Join(excludeDir, "exclude")
+			excludeString := "/.jiri/\n"
+			if err := s.MkdirAll(excludeDir, 0755).WriteFile(excludeFile, []byte(excludeString), 0644).Done(); err != nil {
+				return err
+			}
+		}
+		if op.Project().GitHooks == "" {
+			continue
+		}
+		if op.Kind() != "create" && op.Kind() != "move" && op.Kind() != "update" {
+			continue
+		}
+		// Apply git hooks, overwriting any existing hooks.  Jiri is in control of
+		// writing all hooks.
+		gitHooksDstDir := filepath.Join(op.Project().Path, ".git", "hooks")
+		gitHooksSrcDir := filepath.Join(jirix.Root, op.Root(), op.Project().GitHooks)
+		// Copy the specified GitHooks directory into the project's git
+		// hook directory.  We walk the file system, creating directories
+		// and copying files as we encounter them.
+		copyFn := func(path string, info os.FileInfo, err error) error {
+			if err != nil {
+				return err
+			}
+			relPath, err := filepath.Rel(gitHooksSrcDir, path)
+			if err != nil {
+				return err
+			}
+			dst := filepath.Join(gitHooksDstDir, relPath)
+			if info.IsDir() {
+				return s.MkdirAll(dst, 0755).Done()
+			}
+			src, err := s.ReadFile(path)
+			if err != nil {
+				return err
+			}
+			// The file *must* be executable to be picked up by git.
+			return s.WriteFile(dst, src, 0755).Done()
+		}
+		if err := filepath.Walk(gitHooksSrcDir, copyFn); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
 // writeMetadata stores the given project metadata in the directory
 // identified by the given path.
 func writeMetadata(jirix *jiri.X, project Project, dir string) (e error) {
@@ -1783,50 +1937,6 @@
 		if err := gitutil.New(jirix.NewSeq()).Clone(op.project.Remote, tmpDir); err != nil {
 			return err
 		}
-
-		// Apply git hooks.  We're creating this repo, so there's no danger of
-		// 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.
-		gitHooksDstDir := filepath.Join(tmpDir, ".git", "hooks")
-		if op.project.GitHooks != "" {
-			gitHooksSrcDir := filepath.Join(jirix.Root, op.root, op.project.GitHooks)
-			// Copy the specified GitHooks directory into the project's git
-			// hook directory.  We walk the file system, creating directories
-			// and copying files as we encounter them.
-			copyFn := func(path string, info os.FileInfo, err error) error {
-				if err != nil {
-					return err
-				}
-				relPath, err := filepath.Rel(gitHooksSrcDir, path)
-				if err != nil {
-					return err
-				}
-				dst := filepath.Join(gitHooksDstDir, relPath)
-				if info.IsDir() {
-					return s.MkdirAll(dst, perm).Done()
-				}
-				src, err := s.ReadFile(path)
-				if err != nil {
-					return err
-				}
-				return s.WriteFile(dst, src, perm).Done()
-			}
-			if err := filepath.Walk(gitHooksSrcDir, copyFn); err != nil {
-				return err
-			}
-		}
-
-		// Apply exclusion for /.jiri/. We're creating the repo so we can safely
-		// write to .git/info/exclude
-		excludeString := "/.jiri/\n"
-		excludeDir := filepath.Join(tmpDir, ".git", "info")
-		excludeFile := filepath.Join(excludeDir, "exclude")
-		if err := s.MkdirAll(excludeDir, os.FileMode(0750)).
-			WriteFile(excludeFile, []byte(excludeString), perm).Done(); err != nil {
-			return err
-		}
-
 		cwd, err := os.Getwd()
 		if err != nil {
 			return err
@@ -1835,6 +1945,7 @@
 		if err := s.Chdir(tmpDir).Done(); err != nil {
 			return err
 		}
+		// TODO(toddw): Why call Reset here, when resetProject is called just below?
 		if err := gitutil.New(jirix.NewSeq()).Reset(op.project.Revision); err != nil {
 			return err
 		}
@@ -1848,7 +1959,7 @@
 		Rename(tmpDir, op.destination).Done(); err != nil {
 		return err
 	}
-	if err := resetProject(jirix, op.project); err != nil {
+	if err := syncProjectMaster(jirix, op.project); err != nil {
 		return err
 	}
 	return addProjectToManifest(jirix, manifest, op.project)
@@ -1952,7 +2063,7 @@
 	if err := reportNonMaster(jirix, op.project); err != nil {
 		return err
 	}
-	if err := resetProject(jirix, op.project); err != nil {
+	if err := syncProjectMaster(jirix, op.project); err != nil {
 		return err
 	}
 	if err := writeMetadata(jirix, op.project, op.project.Path); err != nil {
@@ -1996,7 +2107,7 @@
 	if err := reportNonMaster(jirix, op.project); err != nil {
 		return err
 	}
-	if err := resetProject(jirix, op.project); err != nil {
+	if err := syncProjectMaster(jirix, op.project); err != nil {
 		return err
 	}
 	if err := writeMetadata(jirix, op.project, op.project.Path); err != nil {
@@ -2112,29 +2223,11 @@
 
 func computeOp(local, remote *Project, gc bool, root string) 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,
-				root:        root,
-			}}
-		}
-		if local.Revision != remote.Revision {
-			return updateOperation{commonOperation{
-				destination: remote.Path,
-				project:     *remote,
-				source:      local.Path,
-				root:        root,
-			}}
-		}
-		return nullOperation{commonOperation{
+	case local == nil && remote != nil:
+		return createOperation{commonOperation{
 			destination: remote.Path,
 			project:     *remote,
-			source:      local.Path,
+			source:      "",
 			root:        root,
 		}}
 	case local != nil && remote == nil:
@@ -2144,13 +2237,32 @@
 			source:      local.Path,
 			root:        root,
 		}, gc}
-	case local == nil && remote != nil:
-		return createOperation{commonOperation{
-			destination: remote.Path,
-			project:     *remote,
-			source:      "",
-			root:        root,
-		}}
+	case local != nil && remote != nil:
+		switch {
+		case 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,
+				root:        root,
+			}}
+		case local.Revision != remote.Revision:
+			return updateOperation{commonOperation{
+				destination: remote.Path,
+				project:     *remote,
+				source:      local.Path,
+				root:        root,
+			}}
+		default:
+			return nullOperation{commonOperation{
+				destination: remote.Path,
+				project:     *remote,
+				source:      local.Path,
+				root:        root,
+			}}
+		}
 	default:
 		panic("jiri: computeOp called with nil local and remote")
 	}
@@ -2159,7 +2271,7 @@
 // ParseNames identifies the set of projects that a jiri command should
 // be applied to.
 func ParseNames(jirix *jiri.X, args []string, defaultProjects map[string]struct{}) (Projects, error) {
-	manifestProjects, _, err := ReadJiriManifest(jirix)
+	manifestProjects, _, err := LoadManifest(jirix)
 	if err != nil {
 		return nil, err
 	}
diff --git a/project/project_test.go b/project/project_test.go
index 8db3887..f19cc97 100644
--- a/project/project_test.go
+++ b/project/project_test.go
@@ -579,17 +579,17 @@
 
 	// Set up the cycle .jiri_manifest -> A -> B -> A
 	jiriManifest := project.Manifest{
-		FileImports: []project.FileImport{
+		LocalImports: []project.LocalImport{
 			{File: "A"},
 		},
 	}
 	manifestA := project.Manifest{
-		FileImports: []project.FileImport{
+		LocalImports: []project.LocalImport{
 			{File: "B"},
 		},
 	}
 	manifestB := project.Manifest{
-		FileImports: []project.FileImport{
+		LocalImports: []project.LocalImport{
 			{File: "A"},
 		},
 	}
@@ -623,23 +623,17 @@
 	// 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,
-			}},
+			{Manifest: "A", Name: "n1", Remote: remote1},
 		},
 	}
 	manifestA := project.Manifest{
 		Imports: []project.Import{
-			{Manifest: "B", Project: project.Project{
-				Name: "n2", Path: "p2", Remote: remote2,
-			}},
+			{Manifest: "B", Name: "n2", Remote: remote2},
 		},
 	}
 	manifestB := project.Manifest{
 		Imports: []project.Import{
-			{Manifest: "A", Project: project.Project{
-				Name: "n3", Path: "p3", Remote: remote1,
-			}},
+			{Manifest: "A", Name: "n3", Remote: remote1},
 		},
 	}
 	if err := jiriManifest.ToFile(jirix, jirix.JiriManifestFile()); err != nil {
@@ -675,32 +669,26 @@
 	// 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,
-			}},
+			{Manifest: "A", Root: "r1", Name: "n1", Remote: remote1},
 		},
 	}
 	manifestA := project.Manifest{
 		Imports: []project.Import{
-			{Manifest: "B", Root: "r2", Project: project.Project{
-				Name: "n2", Path: "p2", Remote: remote2,
-			}},
+			{Manifest: "B", Root: "r2", Name: "n2", Remote: remote2},
 		},
 	}
 	manifestB := project.Manifest{
-		FileImports: []project.FileImport{
+		LocalImports: []project.LocalImport{
 			{File: "C"},
 		},
 	}
 	manifestC := project.Manifest{
 		Imports: []project.Import{
-			{Manifest: "D", Root: "r3", Project: project.Project{
-				Name: "n3", Path: "p3", Remote: remote1,
-			}},
+			{Manifest: "D", Root: "r3", Name: "n3", Remote: remote1},
 		},
 	}
 	manifestD := project.Manifest{
-		FileImports: []project.FileImport{
+		LocalImports: []project.LocalImport{
 			{File: "A"},
 		},
 	}
@@ -928,31 +916,45 @@
 				Label: "label",
 				Imports: []project.Import{
 					{
-						Manifest: "manifest",
-						Project: project.Project{
-							Path:         "manifest",
-							Protocol:     "git",
-							Remote:       "remote",
-							RemoteBranch: "master",
-							Revision:     "HEAD",
-						},
+						Manifest:     "manifest1",
+						Name:         "remoteimport1",
+						Protocol:     "git",
+						Remote:       "remote1",
+						RemoteBranch: "master",
 					},
-					{Project: project.Project{Name: "localimport"}},
+					{
+						Manifest:     "manifest2",
+						Name:         "remoteimport2",
+						Protocol:     "git",
+						Remote:       "remote2",
+						RemoteBranch: "branch2",
+					},
+					{
+						Name: "oldimport",
+					},
 				},
-				FileImports: []project.FileImport{
+				LocalImports: []project.LocalImport{
 					{File: "fileimport"},
 				},
 				Projects: []project.Project{
 					{
+						Name:         "project1",
+						Path:         "path1",
+						Protocol:     "git",
+						Remote:       "remote1",
+						RemoteBranch: "master",
+						Revision:     "HEAD",
 						GerritHost:   "https://test-review.googlesource.com",
 						GitHooks:     "path/to/githooks",
 						RunHook:      "path/to/hook",
-						Name:         "project",
-						Path:         "path",
+					},
+					{
+						Name:         "project2",
+						Path:         "path2",
 						Protocol:     "git",
-						Remote:       "remote",
-						RemoteBranch: "otherbranch",
-						Revision:     "rev",
+						Remote:       "remote2",
+						RemoteBranch: "branch2",
+						Revision:     "rev2",
 					},
 				},
 				Tools: []project.Tool{
@@ -965,12 +967,14 @@
 			},
 			`<manifest label="label">
   <imports>
-    <import manifest="manifest" remote="remote"/>
-    <import name="localimport"/>
-    <fileimport file="fileimport"/>
+    <import manifest="manifest1" name="remoteimport1" remote="remote1"/>
+    <import manifest="manifest2" name="remoteimport2" remote="remote2" remotebranch="branch2"/>
+    <import name="oldimport"/>
+    <localimport file="fileimport"/>
   </imports>
   <projects>
-    <project name="project" path="path" remote="remote" remotebranch="otherbranch" revision="rev" gerrithost="https://test-review.googlesource.com" githooks="path/to/githooks" runhook="path/to/hook"/>
+    <project name="project1" path="path1" remote="remote1" gerrithost="https://test-review.googlesource.com" githooks="path/to/githooks" runhook="path/to/hook"/>
+    <project name="project2" path="path2" remote="remote2" remotebranch="branch2" revision="rev2"/>
   </projects>
   <tools>
     <tool data="tooldata" name="tool" project="toolproject"/>
@@ -1008,25 +1012,27 @@
 		{
 			// Default fields are dropped when marshaled, and added when unmarshaled.
 			project.Project{
-				Name:         "project",
-				Path:         "path",
+				Name:         "project1",
+				Path:         "path1",
 				Protocol:     "git",
-				Remote:       "remote",
+				Remote:       "remote1",
 				RemoteBranch: "master",
 				Revision:     "HEAD",
 			},
-			`<project name="project" path="path" remote="remote"/>`,
+			`<project name="project1" path="path1" remote="remote1"/>
+`,
 		},
 		{
 			project.Project{
-				Name:         "project",
-				Path:         "path",
+				Name:         "project2",
+				Path:         "path2",
 				Protocol:     "git",
-				Remote:       "remote",
-				RemoteBranch: "otherbranch",
-				Revision:     "rev",
+				Remote:       "remote2",
+				RemoteBranch: "branch2",
+				Revision:     "rev2",
 			},
-			`<project name="project" path="path" remote="remote" remotebranch="otherbranch" revision="rev"/>`,
+			`<project name="project2" path="path2" remote="remote2" remotebranch="branch2" revision="rev2"/>
+`,
 		},
 	}
 	for index, test := range tests {
@@ -1059,6 +1065,7 @@
 		XML     string
 		Project project.Project
 	}{
+		// Make sure <Project> opening tag is accepted.
 		{
 			`<Project name="project" path="path" remote="remote"/>`,
 			project.Project{
@@ -1070,6 +1077,7 @@
 				Revision:     "HEAD",
 			},
 		},
+		// Make sure <Project> opening and closing tags are accepted.
 		{
 			`<Project name="project" path="path" remote="remote"></Project>`,
 			project.Project{
@@ -1081,14 +1089,15 @@
 				Revision:     "HEAD",
 			},
 		},
+		// Make sure "this_attribute_should_be_ignored" is silently ignored.
 		{
-			`<Project this_attribute_should_be_ignored="junk" name="project" path="path" remote="remote" remotebranch="otherbranch" revision="rev"></Project>`,
+			`<Project this_attribute_should_be_ignored="junk" name="project" path="path" remote="remote" remotebranch="branch" revision="rev"></Project>`,
 			project.Project{
 				Name:         "project",
 				Path:         "path",
 				Protocol:     "git",
 				Remote:       "remote",
-				RemoteBranch: "otherbranch",
+				RemoteBranch: "branch",
 				Revision:     "rev",
 			},
 		},
@@ -1107,64 +1116,3 @@
 		}
 	}
 }
-
-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/rebuild.go b/rebuild.go
index 3f68b9a..984ee35 100644
--- a/rebuild.go
+++ b/rebuild.go
@@ -29,7 +29,7 @@
 }
 
 func runRebuild(jirix *jiri.X, args []string) (e error) {
-	_, tools, err := project.ReadJiriManifest(jirix)
+	_, tools, err := project.LoadManifest(jirix)
 	if err != nil {
 		return err
 	}
diff --git a/snapshot.go b/snapshot.go
index 168598f..b0e6262 100644
--- a/snapshot.go
+++ b/snapshot.go
@@ -116,9 +116,10 @@
 
 	// Execute the above function in the snapshot directory on a clean master branch.
 	p := project.Project{
-		Path:     snapshotDir,
-		Protocol: "git",
-		Revision: "HEAD",
+		Path:         snapshotDir,
+		Protocol:     "git",
+		RemoteBranch: "master",
+		Revision:     "HEAD",
 	}
 	return project.ApplyToLocalMaster(jirix, project.Projects{p.Key(): p}, createFn)
 }
diff --git a/upgrade.go b/upgrade.go
index 1209c4f..382e18e 100644
--- a/upgrade.go
+++ b/upgrade.go
@@ -82,65 +82,56 @@
 		return jirix.UsageErrorf("must specify upgrade kind")
 	}
 	kind := args[0]
-	var argRemote, argRoot, argPath, argManifest string
+	var argRemote, argName, argManifest string
 	switch kind {
 	case "v23":
 		argRemote = "https://vanadium.googlesource.com/manifest"
-		argRoot, argPath, argManifest = "", "manifest", "public"
+		argName, argManifest = "manifest", "public"
 	case "fuchsia":
-		// TODO(toddw): Confirm these choices.
 		argRemote = "https://github.com/effenel/fnl-start.git"
-		argRoot, argPath, argManifest = "", "manifest", "default"
+		argName, argManifest = "fnl-start", "manifest/fuchsia"
 	default:
 		return jirix.UsageErrorf("unknown upgrade kind %q", kind)
 	}
 	// Initialize manifest from .local_manifest.
-	hasLocalFile := false
+	hasLocalFile := true
 	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 {
-			if oldImport.Remote != "" {
-				// This is a new-style remote import, carry it over directly.
-				newImports = append(newImports, oldImport)
-				continue
-			}
-			// This is an old-style file import, convert it to the new style.
-			oldName := oldImport.Name
-			if kind == "v23" && oldName == "default" {
-				oldName = "public" // default no longer exists, now it's just public.
-			}
-			if !seenOldImport {
-				// This is the first old import, update the manifest name for the remote
-				// import we'll be adding later.
-				argManifest = oldName
-				seenOldImport = true
-			} else {
-				// Convert import from name="foo" to file="manifest/foo"
-				manifest.FileImports = append(manifest.FileImports, project.FileImport{
-					File: filepath.Join(argRoot, argPath, oldName),
-				})
-			}
+	if err != nil {
+		if !runutil.IsNotExist(err) {
+			return err
 		}
-		manifest.Imports = newImports
-	}
-	if manifest == nil {
+		hasLocalFile = false
 		manifest = &project.Manifest{}
 	}
-	// Add remote import.
-	manifest.Imports = append(manifest.Imports, project.Import{
-		Manifest: argManifest,
-		Root:     argRoot,
-		Project: project.Project{
-			Path:   argPath,
-			Remote: argRemote,
-		},
-	})
+	oldImports := manifest.Imports
+	manifest.Imports = nil
+	for _, oldImport := range oldImports {
+		if oldImport.Remote != "" {
+			// This is a new-style remote import, carry it over directly.
+			manifest.Imports = append(manifest.Imports, oldImport)
+			continue
+		}
+		// This is an old-style import, convert it to the new style.
+		oldName := oldImport.Name
+		switch {
+		case kind == "v23" && oldName == "default":
+			oldName = "public"
+		case kind == "fuchsia" && oldName == "default":
+			oldName = "manifest/fuchsia"
+		}
+		manifest.Imports = append(manifest.Imports, project.Import{
+			Manifest: oldName,
+			Name:     argName,
+			Remote:   argRemote,
+		})
+	}
+	if len(manifest.Imports) == 0 {
+		manifest.Imports = append(manifest.Imports, project.Import{
+			Manifest: argManifest,
+			Name:     argName,
+			Remote:   argRemote,
+		})
+	}
 	// Write output to .jiri_manifest file.
 	outFile := jirix.JiriManifestFile()
 	if _, err := os.Stat(outFile); err == nil {
diff --git a/upgrade_test.go b/upgrade_test.go
index f9e5733..4361c1b 100644
--- a/upgrade_test.go
+++ b/upgrade_test.go
@@ -41,7 +41,7 @@
 			Args: []string{"v23"},
 			Want: `<manifest>
   <imports>
-    <import manifest="public" remote="https://vanadium.googlesource.com/manifest"/>
+    <import manifest="public" name="manifest" remote="https://vanadium.googlesource.com/manifest"/>
   </imports>
 </manifest>
 `,
@@ -56,7 +56,7 @@
 `,
 			Want: `<manifest>
   <imports>
-    <import manifest="public" remote="https://vanadium.googlesource.com/manifest"/>
+    <import manifest="public" name="manifest" remote="https://vanadium.googlesource.com/manifest"/>
   </imports>
 </manifest>
 `,
@@ -71,7 +71,7 @@
 `,
 			Want: `<manifest>
   <imports>
-    <import manifest="private" remote="https://vanadium.googlesource.com/manifest"/>
+    <import manifest="private" name="manifest" remote="https://vanadium.googlesource.com/manifest"/>
   </imports>
 </manifest>
 `,
@@ -88,9 +88,9 @@
 `,
 			Want: `<manifest>
   <imports>
-    <import manifest="private" remote="https://vanadium.googlesource.com/manifest"/>
-    <fileimport file="manifest/infrastructure"/>
-    <fileimport file="manifest/public"/>
+    <import manifest="private" name="manifest" remote="https://vanadium.googlesource.com/manifest"/>
+    <import manifest="infrastructure" name="manifest" remote="https://vanadium.googlesource.com/manifest"/>
+    <import manifest="public" name="manifest" remote="https://vanadium.googlesource.com/manifest"/>
   </imports>
 </manifest>
 `,
@@ -107,9 +107,9 @@
 `,
 			Want: `<manifest>
   <imports>
-    <import manifest="public" remote="https://vanadium.googlesource.com/manifest"/>
-    <fileimport file="manifest/infrastructure"/>
-    <fileimport file="manifest/private"/>
+    <import manifest="public" name="manifest" remote="https://vanadium.googlesource.com/manifest"/>
+    <import manifest="infrastructure" name="manifest" remote="https://vanadium.googlesource.com/manifest"/>
+    <import manifest="private" name="manifest" remote="https://vanadium.googlesource.com/manifest"/>
   </imports>
 </manifest>
 `,
@@ -124,7 +124,7 @@
 			Args: []string{"fuchsia"},
 			Want: `<manifest>
   <imports>
-    <import manifest="default" remote="https://github.com/effenel/fnl-start.git"/>
+    <import manifest="manifest/fuchsia" name="fnl-start" remote="https://github.com/effenel/fnl-start.git"/>
   </imports>
 </manifest>
 `,
@@ -139,7 +139,7 @@
 `,
 			Want: `<manifest>
   <imports>
-    <import manifest="default" remote="https://github.com/effenel/fnl-start.git"/>
+    <import manifest="manifest/fuchsia" name="fnl-start" remote="https://github.com/effenel/fnl-start.git"/>
   </imports>
 </manifest>
 `,
@@ -154,7 +154,7 @@
 `,
 			Want: `<manifest>
   <imports>
-    <import manifest="private" remote="https://github.com/effenel/fnl-start.git"/>
+    <import manifest="private" name="fnl-start" remote="https://github.com/effenel/fnl-start.git"/>
   </imports>
 </manifest>
 `,
@@ -171,9 +171,9 @@
 `,
 			Want: `<manifest>
   <imports>
-    <import manifest="private" remote="https://github.com/effenel/fnl-start.git"/>
-    <fileimport file="manifest/infrastructure"/>
-    <fileimport file="manifest/default"/>
+    <import manifest="private" name="fnl-start" remote="https://github.com/effenel/fnl-start.git"/>
+    <import manifest="infrastructure" name="fnl-start" remote="https://github.com/effenel/fnl-start.git"/>
+    <import manifest="manifest/fuchsia" name="fnl-start" remote="https://github.com/effenel/fnl-start.git"/>
   </imports>
 </manifest>
 `,
@@ -190,9 +190,9 @@
 `,
 			Want: `<manifest>
   <imports>
-    <import manifest="default" remote="https://github.com/effenel/fnl-start.git"/>
-    <fileimport file="manifest/infrastructure"/>
-    <fileimport file="manifest/private"/>
+    <import manifest="manifest/fuchsia" name="fnl-start" remote="https://github.com/effenel/fnl-start.git"/>
+    <import manifest="infrastructure" name="fnl-start" remote="https://github.com/effenel/fnl-start.git"/>
+    <import manifest="private" name="fnl-start" remote="https://github.com/effenel/fnl-start.git"/>
   </imports>
 </manifest>
 `,
@@ -210,6 +210,7 @@
 }
 
 func testUpgrade(opts gosh.Opts, jiriTool string, test upgradeTestCase) error {
+	opts.PropagateChildOutput = true
 	sh := gosh.NewShell(opts)
 	defer sh.Cleanup()
 	jiriRoot := sh.MakeTempDir()
@@ -267,7 +268,7 @@
 	localData := `<manifest/>`
 	jiriData := `<manifest>
   <imports>
-    <import manifest="public" remote="https://vanadium.googlesource.com/manifest"/>
+    <import manifest="public" name="manifest" remote="https://vanadium.googlesource.com/manifest"/>
   </imports>
 </manifest>
 `