Add multi-manifest support with <submanifest> element

To be addressed in another change:
 - a partial `repo sync` (with a list of projects/paths to sync)
   requires `--this-tree-only`.

Change-Id: I6c7400bf001540e9d7694fa70934f8f204cb5f57
Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/322657
Tested-by: LaMont Jones <lamontjones@google.com>
Reviewed-by: Mike Frysinger <vapier@google.com>
diff --git a/command.py b/command.py
index b972a0b..12fe417 100644
--- a/command.py
+++ b/command.py
@@ -61,13 +61,21 @@
   # it is the number of parallel jobs to default to.
   PARALLEL_JOBS = None
 
+  # Whether this command supports Multi-manifest.  If False, then main.py will
+  # iterate over the manifests and invoke the command once per (sub)manifest.
+  # This is only checked after calling ValidateOptions, so that partially
+  # migrated subcommands can set it to False.
+  MULTI_MANIFEST_SUPPORT = True
+
   def __init__(self, repodir=None, client=None, manifest=None, gitc_manifest=None,
-               git_event_log=None):
+               git_event_log=None, outer_client=None, outer_manifest=None):
     self.repodir = repodir
     self.client = client
+    self.outer_client = outer_client or client
     self.manifest = manifest
     self.gitc_manifest = gitc_manifest
     self.git_event_log = git_event_log
+    self.outer_manifest = outer_manifest
 
     # Cache for the OptionParser property.
     self._optparse = None
@@ -135,6 +143,18 @@
           type=int, default=self.PARALLEL_JOBS,
           help=f'number of jobs to run in parallel (default: {default})')
 
+    m = p.add_option_group('Multi-manifest options')
+    m.add_option('--outer-manifest', action='store_true',
+                 help='operate starting at the outermost manifest')
+    m.add_option('--no-outer-manifest', dest='outer_manifest',
+                 action='store_false', default=None,
+                 help='do not operate on outer manifests')
+    m.add_option('--this-manifest-only', action='store_true', default=None,
+                 help='only operate on this (sub)manifest')
+    m.add_option('--no-this-manifest-only', '--all-manifests',
+                 dest='this_manifest_only', action='store_false',
+                 help='operate on this manifest and its submanifests')
+
   def _Options(self, p):
     """Initialize the option parser with subcommand-specific options."""
 
@@ -252,16 +272,19 @@
     return project
 
   def GetProjects(self, args, manifest=None, groups='', missing_ok=False,
-                  submodules_ok=False):
+                  submodules_ok=False, all_manifests=False):
     """A list of projects that match the arguments.
     """
-    if not manifest:
-      manifest = self.manifest
-    all_projects_list = manifest.projects
+    if all_manifests:
+      if not manifest:
+        manifest = self.manifest.outer_client
+      all_projects_list = manifest.all_projects
+    else:
+      if not manifest:
+        manifest = self.manifest
+      all_projects_list = manifest.projects
     result = []
 
-    mp = manifest.manifestProject
-
     if not groups:
       groups = manifest.GetGroupsStr()
     groups = [x for x in re.split(r'[,\s]+', groups) if x]
@@ -282,12 +305,19 @@
       for arg in args:
         # We have to filter by manifest groups in case the requested project is
         # checked out multiple times or differently based on them.
-        projects = [project for project in manifest.GetProjectsWithName(arg)
+        projects = [project for project in manifest.GetProjectsWithName(
+                        arg, all_manifests=all_manifests)
                     if project.MatchesGroups(groups)]
 
         if not projects:
           path = os.path.abspath(arg).replace('\\', '/')
-          project = self._GetProjectByPath(manifest, path)
+          tree = manifest
+          if all_manifests:
+            # Look for the deepest matching submanifest.
+            for tree in reversed(list(manifest.all_manifests)):
+              if path.startswith(tree.topdir):
+                break
+          project = self._GetProjectByPath(tree, path)
 
           # If it's not a derived project, update path->project mapping and
           # search again, as arg might actually point to a derived subproject.
@@ -308,7 +338,8 @@
 
         for project in projects:
           if not missing_ok and not project.Exists:
-            raise NoSuchProjectError('%s (%s)' % (arg, project.relpath))
+            raise NoSuchProjectError('%s (%s)' % (
+                arg, project.RelPath(local=not all_manifests)))
           if not project.MatchesGroups(groups):
             raise InvalidProjectGroupsError(arg)
 
@@ -319,12 +350,22 @@
     result.sort(key=_getpath)
     return result
 
-  def FindProjects(self, args, inverse=False):
+  def FindProjects(self, args, inverse=False, all_manifests=False):
+    """Find projects from command line arguments.
+
+    Args:
+      args: a list of (case-insensitive) strings, projects to search for.
+      inverse: a boolean, if True, then projects not matching any |args| are
+               returned.
+      all_manifests: a boolean, if True then all manifests and submanifests are
+                     used.  If False, then only the local (sub)manifest is used.
+    """
     result = []
     patterns = [re.compile(r'%s' % a, re.IGNORECASE) for a in args]
-    for project in self.GetProjects(''):
+    for project in self.GetProjects('', all_manifests=all_manifests):
+      paths = [project.name, project.RelPath(local=not all_manifests)]
       for pattern in patterns:
-        match = pattern.search(project.name) or pattern.search(project.relpath)
+        match = any(pattern.search(x) for x in paths)
         if not inverse and match:
           result.append(project)
           break
@@ -333,9 +374,24 @@
       else:
         if inverse:
           result.append(project)
-    result.sort(key=lambda project: project.relpath)
+    result.sort(key=lambda project: (project.manifest.path_prefix,
+                                     project.relpath))
     return result
 
+  def ManifestList(self, opt):
+    """Yields all of the manifests to traverse.
+
+    Args:
+      opt: The command options.
+    """
+    top = self.outer_manifest
+    if opt.outer_manifest is False or opt.this_manifest_only:
+      top = self.manifest
+    yield top
+    if not opt.this_manifest_only:
+      for child in top.all_children:
+        yield child
+
 
 class InteractiveCommand(Command):
   """Command which requires user interaction on the tty and
diff --git a/docs/internal-fs-layout.md b/docs/internal-fs-layout.md
index 0e83051..a9bd1d2 100644
--- a/docs/internal-fs-layout.md
+++ b/docs/internal-fs-layout.md
@@ -50,6 +50,10 @@
 For more documentation on the manifest format, including the local_manifests
 support, see the [manifest-format.md] file.
 
+*   `submanifests/{submanifest.path}/`: The path prefix to the manifest state of
+    a submanifest included in a multi-manifest checkout.  The outermost manifest
+    manifest state is found adjacent to `submanifests/`.
+
 *   `manifests/`: A git checkout of the manifest project.  Its `.git/` state
     points to the `manifest.git` bare checkout (see below).  It tracks the git
     branch specified at `repo init` time via `--manifest-branch`.
diff --git a/docs/manifest-format.md b/docs/manifest-format.md
index 8e0049b..7c0a7da 100644
--- a/docs/manifest-format.md
+++ b/docs/manifest-format.md
@@ -26,6 +26,7 @@
                       remote*,
                       default?,
                       manifest-server?,
+                      submanifest*?,
                       remove-project*,
                       project*,
                       extend-project*,
@@ -57,6 +58,15 @@
   <!ELEMENT manifest-server EMPTY>
   <!ATTLIST manifest-server url CDATA #REQUIRED>
 
+  <!ELEMENT submanifest EMPTY>
+  <!ATTLIST submanifest name           ID #REQUIRED>
+  <!ATTLIST submanifest remote         IDREF #IMPLIED>
+  <!ATTLIST submanifest project        CDATA #IMPLIED>
+  <!ATTLIST submanifest manifest-name  CDATA #IMPLIED>
+  <!ATTLIST submanifest revision       CDATA #IMPLIED>
+  <!ATTLIST submanifest path           CDATA #IMPLIED>
+  <!ATTLIST submanifest groups         CDATA #IMPLIED>
+
   <!ELEMENT project (annotation*,
                      project*,
                      copyfile*,
@@ -236,6 +246,60 @@
 is given.
 
 
+### Element submanifest
+
+One or more submanifest elements may be specified.  Each element describes a
+single manifest to be checked out as a child.
+
+Attribute `name`: A unique name (within the current (sub)manifest) for this
+submanifest. It acts as a default for `revision` below.  The same name can be
+used for submanifests with different parent (sub)manifests.
+
+Attribute `remote`: Name of a previously defined remote element.
+If not supplied the remote given by the default element is used.
+
+Attribute `project`: The manifest project name.  The project's name is appended
+onto its remote's fetch URL to generate the actual URL to configure the Git
+remote with.  The URL gets formed as:
+
+    ${remote_fetch}/${project_name}.git
+
+where ${remote_fetch} is the remote's fetch attribute and
+${project_name} is the project's name attribute.  The suffix ".git"
+is always appended as repo assumes the upstream is a forest of
+bare Git repositories.  If the project has a parent element, its
+name will be prefixed by the parent's.
+
+The project name must match the name Gerrit knows, if Gerrit is
+being used for code reviews.
+
+`project` must not be empty, and may not be an absolute path or use "." or ".."
+path components.  It is always interpreted relative to the remote's fetch
+settings, so if a different base path is needed, declare a different remote
+with the new settings needed.
+
+If not supplied the remote and project for this manifest will be used: `remote`
+cannot be supplied.
+
+Attribute `manifest-name`: The manifest filename in the manifest project.  If
+not supplied, `default.xml` is used.
+
+Attribute `revision`: Name of a Git branch (e.g. "main" or "refs/heads/main"),
+tag (e.g. "refs/tags/stable"), or a commit hash.  If not supplied, `name` is
+used.
+
+Attribute `path`: An optional path relative to the top directory
+of the repo client where the submanifest repo client top directory
+should be placed.  If not supplied, `revision` is used.
+
+`path` may not be an absolute path or use "." or ".." path components.
+
+Attribute `groups`: List of additional groups to which all projects
+in the included submanifest belong. This appends and recurses, meaning
+all projects in submanifests carry all parent submanifest groups.
+Same syntax as the corresponding element of `project`.
+
+
 ### Element project
 
 One or more project elements may be specified.  Each element
@@ -471,7 +535,7 @@
 
 Attribute `groups`: List of additional groups to which all projects
 in the included manifest belong. This appends and recurses, meaning
-all projects in sub-manifests carry all parent include groups.
+all projects in included manifests carry all parent include groups.
 Same syntax as the corresponding element of `project`.
 
 ## Local Manifests {#local-manifests}
diff --git a/git_superproject.py b/git_superproject.py
index 237e57e..299d253 100644
--- a/git_superproject.py
+++ b/git_superproject.py
@@ -92,7 +92,8 @@
     self._branch = manifest.branch
     self._repodir = os.path.abspath(repodir)
     self._superproject_dir = superproject_dir
-    self._superproject_path = os.path.join(self._repodir, superproject_dir)
+    self._superproject_path = manifest.SubmanifestInfoDir(manifest.path_prefix,
+                                                          superproject_dir)
     self._manifest_path = os.path.join(self._superproject_path,
                                        _SUPERPROJECT_MANIFEST_NAME)
     git_name = ''
diff --git a/main.py b/main.py
index 2050cab..6fb688c 100755
--- a/main.py
+++ b/main.py
@@ -127,6 +127,8 @@
                           help='filename of event log to append timeline to')
 global_options.add_option('--git-trace2-event-log', action='store',
                           help='directory to write git trace2 event log to')
+global_options.add_option('--submanifest-path', action='store',
+                          metavar='REL_PATH', help='submanifest path')
 
 
 class _Repo(object):
@@ -217,7 +219,12 @@
     SetDefaultColoring(gopts.color)
 
     git_trace2_event_log = EventLog()
-    repo_client = RepoClient(self.repodir)
+    outer_client = RepoClient(self.repodir)
+    repo_client = outer_client
+    if gopts.submanifest_path:
+      repo_client = RepoClient(self.repodir,
+                               submanifest_path=gopts.submanifest_path,
+                               outer_client=outer_client)
     gitc_manifest = None
     gitc_client_name = gitc_utils.parse_clientdir(os.getcwd())
     if gitc_client_name:
@@ -229,6 +236,8 @@
           repodir=self.repodir,
           client=repo_client,
           manifest=repo_client.manifest,
+          outer_client=outer_client,
+          outer_manifest=outer_client.manifest,
           gitc_manifest=gitc_manifest,
           git_event_log=git_trace2_event_log)
     except KeyError:
@@ -283,7 +292,37 @@
     try:
       cmd.CommonValidateOptions(copts, cargs)
       cmd.ValidateOptions(copts, cargs)
-      result = cmd.Execute(copts, cargs)
+
+      this_manifest_only = copts.this_manifest_only
+      # If not specified, default to using the outer manifest.
+      outer_manifest = copts.outer_manifest is not False
+      if cmd.MULTI_MANIFEST_SUPPORT or this_manifest_only:
+        result = cmd.Execute(copts, cargs)
+      elif outer_manifest and repo_client.manifest.is_submanifest:
+        # The command does not support multi-manifest, we are using a
+        # submanifest, and the command line is for the outermost manifest.
+        # Re-run using the outermost manifest, which will recurse through the
+        # submanifests.
+        gopts.submanifest_path = ''
+        result = self._Run(name, gopts, argv)
+      else:
+        # No multi-manifest support. Run the command in the current
+        # (sub)manifest, and then any child submanifests.
+        result = cmd.Execute(copts, cargs)
+        for submanifest in repo_client.manifest.submanifests.values():
+          spec = submanifest.ToSubmanifestSpec(root=repo_client.outer_client)
+          gopts.submanifest_path = submanifest.repo_client.path_prefix
+          child_argv = argv[:]
+          child_argv.append('--no-outer-manifest')
+          # Not all subcommands support the 3 manifest options, so only add them
+          # if the original command includes them.
+          if hasattr(copts, 'manifest_url'):
+            child_argv.extend(['--manifest-url', spec.manifestUrl])
+          if hasattr(copts, 'manifest_name'):
+            child_argv.extend(['--manifest-name', spec.manifestName])
+          if hasattr(copts, 'manifest_branch'):
+            child_argv.extend(['--manifest-branch', spec.revision])
+          result = self._Run(name, gopts, child_argv) or result
     except (DownloadError, ManifestInvalidRevisionError,
             NoManifestException) as e:
       print('error: in `%s`: %s' % (' '.join([name] + argv), str(e)),
diff --git a/manifest_xml.py b/manifest_xml.py
index 7c5906d..7a4eb1e 100644
--- a/manifest_xml.py
+++ b/manifest_xml.py
@@ -33,6 +33,9 @@
 MANIFEST_FILE_NAME = 'manifest.xml'
 LOCAL_MANIFEST_NAME = 'local_manifest.xml'
 LOCAL_MANIFESTS_DIR_NAME = 'local_manifests'
+SUBMANIFEST_DIR = 'submanifests'
+# Limit submanifests to an arbitrary depth for loop detection.
+MAX_SUBMANIFEST_DEPTH = 8
 
 # Add all projects from local manifest into a group.
 LOCAL_MANIFEST_GROUP_PREFIX = 'local:'
@@ -197,10 +200,122 @@
     self.annotations.append(Annotation(name, value, keep))
 
 
+class _XmlSubmanifest:
+  """Manage the <submanifest> element specified in the manifest.
+
+  Attributes:
+    name: a string, the name for this submanifest.
+    remote: a string, the remote.name for this submanifest.
+    project: a string, the name of the manifest project.
+    revision: a string, the commitish.
+    manifestName: a string, the submanifest file name.
+    groups: a list of strings, the groups to add to all projects in the submanifest.
+    path: a string, the relative path for the submanifest checkout.
+    annotations: (derived) a list of annotations.
+    present: (derived) a boolean, whether the submanifest's manifest file is present.
+  """
+  def __init__(self,
+               name,
+               remote=None,
+               project=None,
+               revision=None,
+               manifestName=None,
+               groups=None,
+               path=None,
+               parent=None):
+    self.name = name
+    self.remote = remote
+    self.project = project
+    self.revision = revision
+    self.manifestName = manifestName
+    self.groups = groups
+    self.path = path
+    self.annotations = []
+    outer_client = parent._outer_client or parent
+    if self.remote and not self.project:
+      raise ManifestParseError(
+          f'Submanifest {name}: must specify project when remote is given.')
+    rc = self.repo_client = RepoClient(
+        parent.repodir, manifestName, parent_groups=','.join(groups) or '',
+        submanifest_path=self.relpath, outer_client=outer_client)
+
+    self.present = os.path.exists(os.path.join(self.repo_client.subdir,
+                                               MANIFEST_FILE_NAME))
+
+  def __eq__(self, other):
+    if not isinstance(other, _XmlSubmanifest):
+      return False
+    return (
+        self.name == other.name and
+        self.remote == other.remote and
+        self.project == other.project and
+        self.revision == other.revision and
+        self.manifestName == other.manifestName and
+        self.groups == other.groups and
+        self.path == other.path and
+        sorted(self.annotations) == sorted(other.annotations))
+
+  def __ne__(self, other):
+    return not self.__eq__(other)
+
+  def ToSubmanifestSpec(self, root):
+    """Return a SubmanifestSpec object, populating attributes"""
+    mp = root.manifestProject
+    remote = root.remotes[self.remote or root.default.remote.name]
+    # If a project was given, generate the url from the remote and project.
+    # If not, use this manifestProject's url.
+    if self.project:
+      manifestUrl = remote.ToRemoteSpec(self.project).url
+    else:
+      manifestUrl = mp.GetRemote(mp.remote.name).url
+    manifestName = self.manifestName or 'default.xml'
+    revision = self.revision or self.name
+    path = self.path or revision.split('/')[-1]
+    groups = self.groups or []
+
+    return SubmanifestSpec(self.name, manifestUrl, manifestName, revision, path,
+                           groups)
+
+  @property
+  def relpath(self):
+    """The path of this submanifest relative to the parent manifest."""
+    revision = self.revision or self.name
+    return self.path or revision.split('/')[-1]
+
+  def GetGroupsStr(self):
+    """Returns the `groups` given for this submanifest."""
+    if self.groups:
+      return ','.join(self.groups)
+    return ''
+
+  def AddAnnotation(self, name, value, keep):
+    """Add annotations to the submanifest."""
+    self.annotations.append(Annotation(name, value, keep))
+
+
+class SubmanifestSpec:
+  """The submanifest element, with all fields expanded."""
+
+  def __init__(self,
+               name,
+               manifestUrl,
+               manifestName,
+               revision,
+               path,
+               groups):
+    self.name = name
+    self.manifestUrl = manifestUrl
+    self.manifestName = manifestName
+    self.revision = revision
+    self.path = path
+    self.groups = groups or []
+
+
 class XmlManifest(object):
   """manages the repo configuration file"""
 
-  def __init__(self, repodir, manifest_file, local_manifests=None):
+  def __init__(self, repodir, manifest_file, local_manifests=None,
+               outer_client=None, parent_groups='', submanifest_path=''):
     """Initialize.
 
     Args:
@@ -210,23 +325,37 @@
           be |repodir|/|MANIFEST_FILE_NAME|.
       local_manifests: Full path to the directory of local override manifests.
           This will usually be |repodir|/|LOCAL_MANIFESTS_DIR_NAME|.
+      outer_client: RepoClient of the outertree.
+      parent_groups: a string, the groups to apply to this projects.
+      submanifest_path: The submanifest root relative to the repo root.
     """
     # TODO(vapier): Move this out of this class.
     self.globalConfig = GitConfig.ForUser()
 
     self.repodir = os.path.abspath(repodir)
-    self.topdir = os.path.dirname(self.repodir)
+    self._CheckLocalPath(submanifest_path)
+    self.topdir = os.path.join(os.path.dirname(self.repodir), submanifest_path)
     self.manifestFile = manifest_file
     self.local_manifests = local_manifests
     self._load_local_manifests = True
+    self.parent_groups = parent_groups
+
+    if outer_client and self.isGitcClient:
+      raise ManifestParseError('Multi-manifest is incompatible with `gitc-init`')
+
+    if submanifest_path and not outer_client:
+      # If passing a submanifest_path, there must be an outer_client.
+      raise ManifestParseError(f'Bad call to {self.__class__.__name__}')
+
+    # If self._outer_client is None, this is not a checkout that supports
+    # multi-tree.
+    self._outer_client = outer_client or self
 
     self.repoProject = MetaProject(self, 'repo',
                                    gitdir=os.path.join(repodir, 'repo/.git'),
                                    worktree=os.path.join(repodir, 'repo'))
 
-    mp = MetaProject(self, 'manifests',
-                     gitdir=os.path.join(repodir, 'manifests.git'),
-                     worktree=os.path.join(repodir, 'manifests'))
+    mp = self.SubmanifestProject(self.path_prefix)
     self.manifestProject = mp
 
     # This is a bit hacky, but we're in a chicken & egg situation: all the
@@ -311,6 +440,31 @@
         ae.setAttribute('value', a.value)
         e.appendChild(ae)
 
+  def _SubmanifestToXml(self, r, doc, root):
+    """Generate XML <submanifest/> node."""
+    e = doc.createElement('submanifest')
+    root.appendChild(e)
+    e.setAttribute('name', r.name)
+    if r.remote is not None:
+      e.setAttribute('remote', r.remote)
+    if r.project is not None:
+      e.setAttribute('project', r.project)
+    if r.manifestName is not None:
+      e.setAttribute('manifest-name', r.manifestName)
+    if r.revision is not None:
+      e.setAttribute('revision', r.revision)
+    if r.path is not None:
+      e.setAttribute('path', r.path)
+    if r.groups:
+      e.setAttribute('groups', r.GetGroupsStr())
+
+    for a in r.annotations:
+      if a.keep == 'true':
+        ae = doc.createElement('annotation')
+        ae.setAttribute('name', a.name)
+        ae.setAttribute('value', a.value)
+        e.appendChild(ae)
+
   def _ParseList(self, field):
     """Parse fields that contain flattened lists.
 
@@ -329,6 +483,8 @@
 
     doc = xml.dom.minidom.Document()
     root = doc.createElement('manifest')
+    if self.is_submanifest:
+      root.setAttribute('path', self.path_prefix)
     doc.appendChild(root)
 
     # Save out the notice.  There's a little bit of work here to give it the
@@ -383,6 +539,11 @@
       root.appendChild(e)
       root.appendChild(doc.createTextNode(''))
 
+    for r in sorted(self.submanifests):
+      self._SubmanifestToXml(self.submanifests[r], doc, root)
+    if self.submanifests:
+      root.appendChild(doc.createTextNode(''))
+
     def output_projects(parent, parent_node, projects):
       for project_name in projects:
         for project in self._projects[project_name]:
@@ -537,6 +698,7 @@
         'project',
         'extend-project',
         'include',
+        'submanifest',
         # These are children of 'project' nodes.
         'annotation',
         'project',
@@ -575,12 +737,74 @@
     """Manifests can modify e if they support extra project attributes."""
 
   @property
+  def is_multimanifest(self):
+    """Whether this is a multimanifest checkout"""
+    return bool(self.outer_client.submanifests)
+
+  @property
+  def is_submanifest(self):
+    """Whether this manifest is a submanifest"""
+    return self._outer_client and self._outer_client != self
+
+  @property
+  def outer_client(self):
+    """The instance of the outermost manifest client"""
+    self._Load()
+    return self._outer_client
+
+  @property
+  def all_manifests(self):
+    """Generator yielding all (sub)manifests."""
+    self._Load()
+    outer = self._outer_client
+    yield outer
+    for tree in outer.all_children:
+      yield tree
+
+  @property
+  def all_children(self):
+    """Generator yielding all child submanifests."""
+    self._Load()
+    for child in self._submanifests.values():
+      if child.repo_client:
+        yield child.repo_client
+        for tree in child.repo_client.all_children:
+          yield tree
+
+  @property
+  def path_prefix(self):
+    """The path of this submanifest, relative to the outermost manifest."""
+    if not self._outer_client or self == self._outer_client:
+      return ''
+    return os.path.relpath(self.topdir, self._outer_client.topdir)
+
+  @property
+  def all_paths(self):
+    """All project paths for all (sub)manifests.  See `paths`."""
+    ret = {}
+    for tree in self.all_manifests:
+      prefix = tree.path_prefix
+      ret.update({os.path.join(prefix, k): v for k, v in tree.paths.items()})
+    return ret
+
+  @property
+  def all_projects(self):
+    """All projects for all (sub)manifests.  See `projects`."""
+    return list(itertools.chain.from_iterable(x._paths.values() for x in self.all_manifests))
+
+  @property
   def paths(self):
+    """Return all paths for this manifest.
+
+    Return:
+      A dictionary of {path: Project()}.  `path` is relative to this manifest.
+    """
     self._Load()
     return self._paths
 
   @property
   def projects(self):
+    """Return a list of all Projects in this manifest."""
     self._Load()
     return list(self._paths.values())
 
@@ -595,6 +819,12 @@
     return self._default
 
   @property
+  def submanifests(self):
+    """All submanifests in this manifest."""
+    self._Load()
+    return self._submanifests
+
+  @property
   def repo_hooks_project(self):
     self._Load()
     return self._repo_hooks_project
@@ -651,8 +881,7 @@
     return self._load_local_manifests and self.local_manifests
 
   def IsFromLocalManifest(self, project):
-    """Is the project from a local manifest?
-    """
+    """Is the project from a local manifest?"""
     return any(x.startswith(LOCAL_MANIFEST_GROUP_PREFIX)
                for x in project.groups)
 
@@ -676,6 +905,50 @@
   def EnableGitLfs(self):
     return self.manifestProject.config.GetBoolean('repo.git-lfs')
 
+  def FindManifestByPath(self, path):
+    """Returns the manifest containing path."""
+    path = os.path.abspath(path)
+    manifest = self._outer_client or self
+    old = None
+    while manifest._submanifests and manifest != old:
+      old = manifest
+      for name in manifest._submanifests:
+        tree = manifest._submanifests[name]
+        if path.startswith(tree.repo_client.manifest.topdir):
+          manifest = tree.repo_client
+          break
+    return manifest
+
+  @property
+  def subdir(self):
+    """Returns the path for per-submanifest objects for this manifest."""
+    return self.SubmanifestInfoDir(self.path_prefix)
+
+  def SubmanifestInfoDir(self, submanifest_path, object_path=''):
+    """Return the path to submanifest-specific info for a submanifest.
+
+    Return the full path of the directory in which to put per-manifest objects.
+
+    Args:
+      submanifest_path: a string, the path of the submanifest, relative to the
+                        outermost topdir.  If empty, then repodir is returned.
+      object_path: a string, relative path to append to the submanifest info
+                   directory path.
+    """
+    if submanifest_path:
+      return os.path.join(self.repodir, SUBMANIFEST_DIR, submanifest_path,
+                          object_path)
+    else:
+      return os.path.join(self.repodir, object_path)
+
+  def SubmanifestProject(self, submanifest_path):
+    """Return a manifestProject for a submanifest."""
+    subdir = self.SubmanifestInfoDir(submanifest_path)
+    mp = MetaProject(self, 'manifests',
+                     gitdir=os.path.join(subdir, 'manifests.git'),
+                     worktree=os.path.join(subdir, 'manifests'))
+    return mp
+
   def GetDefaultGroupsStr(self):
     """Returns the default group string for the platform."""
     return 'default,platform-' + platform.system().lower()
@@ -693,6 +966,7 @@
     self._paths = {}
     self._remotes = {}
     self._default = None
+    self._submanifests = {}
     self._repo_hooks_project = None
     self._superproject = {}
     self._contactinfo = ContactInfo(Wrapper().BUG_URL)
@@ -700,20 +974,29 @@
     self.branch = None
     self._manifest_server = None
 
-  def _Load(self):
+  def _Load(self, initial_client=None, submanifest_depth=0):
+    if submanifest_depth > MAX_SUBMANIFEST_DEPTH:
+      raise ManifestParseError('maximum submanifest depth %d exceeded.' %
+                               MAX_SUBMANIFEST_DEPTH)
     if not self._loaded:
+      if self._outer_client and self._outer_client != self:
+        # This will load all clients.
+        self._outer_client._Load(initial_client=self)
+
       m = self.manifestProject
       b = m.GetBranch(m.CurrentBranch).merge
       if b is not None and b.startswith(R_HEADS):
         b = b[len(R_HEADS):]
       self.branch = b
 
+      parent_groups = self.parent_groups
+
       # The manifestFile was specified by the user which is why we allow include
       # paths to point anywhere.
       nodes = []
       nodes.append(self._ParseManifestXml(
           self.manifestFile, self.manifestProject.worktree,
-          restrict_includes=False))
+          parent_groups=parent_groups, restrict_includes=False))
 
       if self._load_local_manifests and self.local_manifests:
         try:
@@ -722,9 +1005,10 @@
               local = os.path.join(self.local_manifests, local_file)
               # Since local manifests are entirely managed by the user, allow
               # them to point anywhere the user wants.
+              local_group = f'{LOCAL_MANIFEST_GROUP_PREFIX}:{local_file[:-4]}'
               nodes.append(self._ParseManifestXml(
-                  local, self.repodir,
-                  parent_groups=f'{LOCAL_MANIFEST_GROUP_PREFIX}:{local_file[:-4]}',
+                  local, self.subdir,
+                  parent_groups=f'{local_group},{parent_groups}',
                   restrict_includes=False))
         except OSError:
           pass
@@ -743,6 +1027,23 @@
 
       self._loaded = True
 
+      # Now that we have loaded this manifest, load any submanifest  manifests
+      # as well.  We need to do this after self._loaded is set to avoid looping.
+      if self._outer_client:
+        for name in self._submanifests:
+          tree = self._submanifests[name]
+          spec = tree.ToSubmanifestSpec(self)
+          present = os.path.exists(os.path.join(self.subdir, MANIFEST_FILE_NAME))
+          if present and tree.present and not tree.repo_client:
+            if initial_client and initial_client.topdir == self.topdir:
+              tree.repo_client = self
+              tree.present = present
+            elif not os.path.exists(self.subdir):
+              tree.present = False
+          if tree.present:
+            tree.repo_client._Load(initial_client=initial_client,
+                                   submanifest_depth=submanifest_depth + 1)
+
   def _ParseManifestXml(self, path, include_root, parent_groups='',
                         restrict_includes=True):
     """Parse a manifest XML and return the computed nodes.
@@ -832,6 +1133,20 @@
     if self._default is None:
       self._default = _Default()
 
+    submanifest_paths = set()
+    for node in itertools.chain(*node_list):
+      if node.nodeName == 'submanifest':
+        submanifest = self._ParseSubmanifest(node)
+        if submanifest:
+          if submanifest.name in self._submanifests:
+            if submanifest != self._submanifests[submanifest.name]:
+              raise ManifestParseError(
+                  'submanifest %s already exists with different attributes' %
+                  (submanifest.name))
+          else:
+            self._submanifests[submanifest.name] = submanifest
+            submanifest_paths.add(submanifest.relpath)
+
     for node in itertools.chain(*node_list):
       if node.nodeName == 'notice':
         if self._notice is not None:
@@ -859,6 +1174,11 @@
         raise ManifestParseError(
             'duplicate path %s in %s' %
             (project.relpath, self.manifestFile))
+      for tree in submanifest_paths:
+        if project.relpath.startswith(tree):
+          raise ManifestParseError(
+              'project %s conflicts with submanifest path %s' %
+              (project.relpath, tree))
       self._paths[project.relpath] = project
       projects.append(project)
       for subproject in project.subprojects:
@@ -883,8 +1203,10 @@
         if groups:
           groups = self._ParseList(groups)
         revision = node.getAttribute('revision')
-        remote = node.getAttribute('remote')
-        if remote:
+        remote_name = node.getAttribute('remote')
+        if not remote_name:
+          remote = self._default.remote
+        else:
           remote = self._get_remote(node)
 
         named_projects = self._projects[name]
@@ -899,12 +1221,13 @@
           if revision:
             p.SetRevision(revision)
 
-          if remote:
+          if remote_name:
             p.remote = remote.ToRemoteSpec(name)
 
           if dest_path:
             del self._paths[p.relpath]
-            relpath, worktree, gitdir, objdir, _ = self.GetProjectPaths(name, dest_path)
+            relpath, worktree, gitdir, objdir, _ = self.GetProjectPaths(
+                name, dest_path, remote.name)
             p.UpdatePaths(relpath, worktree, gitdir, objdir)
             self._paths[p.relpath] = p
 
@@ -1109,6 +1432,53 @@
 
     return '\n'.join(cleanLines)
 
+  def _ParseSubmanifest(self, node):
+    """Reads a <submanifest> element from the manifest file."""
+    name = self._reqatt(node, 'name')
+    remote = node.getAttribute('remote')
+    if remote == '':
+      remote = None
+    project = node.getAttribute('project')
+    if project == '':
+      project = None
+    revision = node.getAttribute('revision')
+    if revision == '':
+      revision = None
+    manifestName = node.getAttribute('manifest-name')
+    if manifestName == '':
+      manifestName = None
+    groups = ''
+    if node.hasAttribute('groups'):
+      groups = node.getAttribute('groups')
+    groups = self._ParseList(groups)
+    path = node.getAttribute('path')
+    if path == '':
+      path = None
+      if revision:
+        msg = self._CheckLocalPath(revision.split('/')[-1])
+        if msg:
+          raise ManifestInvalidPathError(
+              '<submanifest> invalid "revision": %s: %s' % (revision, msg))
+      else:
+        msg = self._CheckLocalPath(name)
+        if msg:
+          raise ManifestInvalidPathError(
+              '<submanifest> invalid "name": %s: %s' % (name, msg))
+    else:
+      msg = self._CheckLocalPath(path)
+      if msg:
+        raise ManifestInvalidPathError(
+            '<submanifest> invalid "path": %s: %s' % (path, msg))
+
+    submanifest = _XmlSubmanifest(name, remote, project, revision, manifestName,
+                                  groups, path, self)
+
+    for n in node.childNodes:
+      if n.nodeName == 'annotation':
+        self._ParseAnnotation(submanifest, n)
+
+    return submanifest
+
   def _JoinName(self, parent_name, name):
     return os.path.join(parent_name, name)
 
@@ -1172,7 +1542,7 @@
 
     if parent is None:
       relpath, worktree, gitdir, objdir, use_git_worktrees = \
-          self.GetProjectPaths(name, path)
+          self.GetProjectPaths(name, path, remote.name)
     else:
       use_git_worktrees = False
       relpath, worktree, gitdir, objdir = \
@@ -1218,31 +1588,54 @@
 
     return project
 
-  def GetProjectPaths(self, name, path):
+  def GetProjectPaths(self, name, path, remote):
+    """Return the paths for a project.
+
+    Args:
+      name: a string, the name of the project.
+      path: a string, the path of the project.
+      remote: a string, the remote.name of the project.
+    """
     # The manifest entries might have trailing slashes.  Normalize them to avoid
     # unexpected filesystem behavior since we do string concatenation below.
     path = path.rstrip('/')
     name = name.rstrip('/')
+    remote = remote.rstrip('/')
     use_git_worktrees = False
+    use_remote_name = bool(self._outer_client._submanifests)
     relpath = path
     if self.IsMirror:
       worktree = None
       gitdir = os.path.join(self.topdir, '%s.git' % name)
       objdir = gitdir
     else:
+      if use_remote_name:
+        namepath = os.path.join(remote, f'{name}.git')
+      else:
+        namepath = f'{name}.git'
       worktree = os.path.join(self.topdir, path).replace('\\', '/')
-      gitdir = os.path.join(self.repodir, 'projects', '%s.git' % path)
+      gitdir = os.path.join(self.subdir, 'projects', '%s.git' % path)
       # We allow people to mix git worktrees & non-git worktrees for now.
       # This allows for in situ migration of repo clients.
       if os.path.exists(gitdir) or not self.UseGitWorktrees:
-        objdir = os.path.join(self.repodir, 'project-objects', '%s.git' % name)
+        objdir = os.path.join(self.subdir, 'project-objects', namepath)
       else:
         use_git_worktrees = True
-        gitdir = os.path.join(self.repodir, 'worktrees', '%s.git' % name)
+        gitdir = os.path.join(self.repodir, 'worktrees', namepath)
         objdir = gitdir
     return relpath, worktree, gitdir, objdir, use_git_worktrees
 
-  def GetProjectsWithName(self, name):
+  def GetProjectsWithName(self, name, all_manifests=False):
+    """All projects with |name|.
+
+    Args:
+      name: a string, the name of the project.
+      all_manifests: a boolean, if True, then all manifests are searched. If
+                     False, then only this manifest is searched.
+    """
+    if all_manifests:
+      return list(itertools.chain.from_iterable(
+          x._projects.get(name, []) for x in self.all_manifests))
     return self._projects.get(name, [])
 
   def GetSubprojectName(self, parent, submodule_path):
@@ -1498,19 +1891,26 @@
 class RepoClient(XmlManifest):
   """Manages a repo client checkout."""
 
-  def __init__(self, repodir, manifest_file=None):
+  def __init__(self, repodir, manifest_file=None, submanifest_path='', **kwargs):
     self.isGitcClient = False
+    submanifest_path = submanifest_path or ''
+    if submanifest_path:
+      self._CheckLocalPath(submanifest_path)
+      prefix = os.path.join(repodir, SUBMANIFEST_DIR, submanifest_path)
+    else:
+      prefix = repodir
 
-    if os.path.exists(os.path.join(repodir, LOCAL_MANIFEST_NAME)):
+    if os.path.exists(os.path.join(prefix, LOCAL_MANIFEST_NAME)):
       print('error: %s is not supported; put local manifests in `%s` instead' %
-            (LOCAL_MANIFEST_NAME, os.path.join(repodir, LOCAL_MANIFESTS_DIR_NAME)),
+            (LOCAL_MANIFEST_NAME, os.path.join(prefix, LOCAL_MANIFESTS_DIR_NAME)),
             file=sys.stderr)
       sys.exit(1)
 
     if manifest_file is None:
-      manifest_file = os.path.join(repodir, MANIFEST_FILE_NAME)
-    local_manifests = os.path.abspath(os.path.join(repodir, LOCAL_MANIFESTS_DIR_NAME))
-    super().__init__(repodir, manifest_file, local_manifests)
+        manifest_file = os.path.join(prefix, MANIFEST_FILE_NAME)
+    local_manifests = os.path.abspath(os.path.join(prefix, LOCAL_MANIFESTS_DIR_NAME))
+    super().__init__(repodir, manifest_file, local_manifests,
+                     submanifest_path=submanifest_path, **kwargs)
 
     # TODO: Completely separate manifest logic out of the client.
     self.manifest = self
diff --git a/project.py b/project.py
index ff91d18..480dac6 100644
--- a/project.py
+++ b/project.py
@@ -546,6 +546,18 @@
     # project containing repo hooks.
     self.enabled_repo_hooks = []
 
+  def RelPath(self, local=True):
+    """Return the path for the project relative to a manifest.
+
+    Args:
+      local: a boolean, if True, the path is relative to the local
+             (sub)manifest.  If false, the path is relative to the
+             outermost manifest.
+    """
+    if local:
+      return self.relpath
+    return os.path.join(self.manifest.path_prefix, self.relpath)
+
   def SetRevision(self, revisionExpr, revisionId=None):
     """Set revisionId based on revision expression and id"""
     self.revisionExpr = revisionExpr
@@ -2503,22 +2515,21 @@
         mp = self.manifest.manifestProject
         ref_dir = mp.config.GetString('repo.reference') or ''
 
-        if ref_dir or mirror_git:
-          if not mirror_git:
-            mirror_git = os.path.join(ref_dir, self.name + '.git')
-          repo_git = os.path.join(ref_dir, '.repo', 'project-objects',
-                                  self.name + '.git')
-          worktrees_git = os.path.join(ref_dir, '.repo', 'worktrees',
-                                       self.name + '.git')
+        def _expanded_ref_dirs():
+          """Iterate through the possible git reference directory paths."""
+          name = self.name + '.git'
+          yield mirror_git or os.path.join(ref_dir, name)
+          for prefix in '', self.remote.name:
+            yield os.path.join(ref_dir, '.repo', 'project-objects', prefix, name)
+            yield os.path.join(ref_dir, '.repo', 'worktrees', prefix, name)
 
-          if os.path.exists(mirror_git):
-            ref_dir = mirror_git
-          elif os.path.exists(repo_git):
-            ref_dir = repo_git
-          elif os.path.exists(worktrees_git):
-            ref_dir = worktrees_git
-          else:
-            ref_dir = None
+        if ref_dir or mirror_git:
+          found_ref_dir = None
+          for path in _expanded_ref_dirs():
+            if os.path.exists(path):
+              found_ref_dir = path
+              break
+          ref_dir = found_ref_dir
 
           if ref_dir:
             if not os.path.isabs(ref_dir):
diff --git a/subcmds/abandon.py b/subcmds/abandon.py
index 85d85f5..c3d2d5b 100644
--- a/subcmds/abandon.py
+++ b/subcmds/abandon.py
@@ -69,7 +69,8 @@
     nb = args[0]
     err = defaultdict(list)
     success = defaultdict(list)
-    all_projects = self.GetProjects(args[1:])
+    all_projects = self.GetProjects(args[1:], all_manifests=not opt.this_manifest_only)
+    _RelPath = lambda p: p.RelPath(local=opt.this_manifest_only)
 
     def _ProcessResults(_pool, pm, states):
       for (results, project) in states:
@@ -94,7 +95,7 @@
         err_msg = "error: cannot abandon %s" % br
         print(err_msg, file=sys.stderr)
         for proj in err[br]:
-          print(' ' * len(err_msg) + " | %s" % proj.relpath, file=sys.stderr)
+          print(' ' * len(err_msg) + " | %s" % _RelPath(proj), file=sys.stderr)
       sys.exit(1)
     elif not success:
       print('error: no project has local branch(es) : %s' % nb,
@@ -110,5 +111,5 @@
           result = "all project"
         else:
           result = "%s" % (
-              ('\n' + ' ' * width + '| ').join(p.relpath for p in success[br]))
+              ('\n' + ' ' * width + '| ').join(_RelPath(p) for p in success[br]))
         print("%s%s| %s\n" % (br, ' ' * (width - len(br)), result))
diff --git a/subcmds/branches.py b/subcmds/branches.py
index 7b5decc..b89cc2f 100644
--- a/subcmds/branches.py
+++ b/subcmds/branches.py
@@ -98,7 +98,7 @@
   PARALLEL_JOBS = DEFAULT_LOCAL_JOBS
 
   def Execute(self, opt, args):
-    projects = self.GetProjects(args)
+    projects = self.GetProjects(args, all_manifests=not opt.this_manifest_only)
     out = BranchColoring(self.manifest.manifestProject.config)
     all_branches = {}
     project_cnt = len(projects)
@@ -147,6 +147,7 @@
       hdr('%c%c %-*s' % (current, published, width, name))
       out.write(' |')
 
+      _RelPath = lambda p: p.RelPath(local=opt.this_manifest_only)
       if in_cnt < project_cnt:
         fmt = out.write
         paths = []
@@ -154,19 +155,20 @@
         if i.IsSplitCurrent or (in_cnt <= project_cnt - in_cnt):
           in_type = 'in'
           for b in i.projects:
+            relpath = b.project.relpath
             if not i.IsSplitCurrent or b.current:
-              paths.append(b.project.relpath)
+              paths.append(_RelPath(b.project))
             else:
-              non_cur_paths.append(b.project.relpath)
+              non_cur_paths.append(_RelPath(b.project))
         else:
           fmt = out.notinproject
           in_type = 'not in'
           have = set()
           for b in i.projects:
-            have.add(b.project.relpath)
+            have.add(_RelPath(b.project))
           for p in projects:
-            if p.relpath not in have:
-              paths.append(p.relpath)
+            if _RelPath(p) not in have:
+              paths.append(_RelPath(p))
 
         s = ' %s %s' % (in_type, ', '.join(paths))
         if not i.IsSplitCurrent and (width + 7 + len(s) < 80):
diff --git a/subcmds/checkout.py b/subcmds/checkout.py
index 9b42948..768b602 100644
--- a/subcmds/checkout.py
+++ b/subcmds/checkout.py
@@ -47,7 +47,7 @@
     nb = args[0]
     err = []
     success = []
-    all_projects = self.GetProjects(args[1:])
+    all_projects = self.GetProjects(args[1:], all_manifests=not opt.this_manifest_only)
 
     def _ProcessResults(_pool, pm, results):
       for status, project in results:
diff --git a/subcmds/diff.py b/subcmds/diff.py
index 00a7ec2..a1f4ba8 100644
--- a/subcmds/diff.py
+++ b/subcmds/diff.py
@@ -50,7 +50,7 @@
     return (ret, buf.getvalue())
 
   def Execute(self, opt, args):
-    all_projects = self.GetProjects(args)
+    all_projects = self.GetProjects(args, all_manifests=not opt.this_manifest_only)
 
     def _ProcessResults(_pool, _output, results):
       ret = 0
diff --git a/subcmds/diffmanifests.py b/subcmds/diffmanifests.py
index f6cc30a..0e5f410 100644
--- a/subcmds/diffmanifests.py
+++ b/subcmds/diffmanifests.py
@@ -179,6 +179,9 @@
   def ValidateOptions(self, opt, args):
     if not args or len(args) > 2:
       self.OptionParser.error('missing manifests to diff')
+    if opt.this_manifest_only is False:
+      raise self.OptionParser.error(
+          '`diffmanifest` only supports the current tree')
 
   def Execute(self, opt, args):
     self.out = _Coloring(self.client.globalConfig)
diff --git a/subcmds/download.py b/subcmds/download.py
index 523f25e..1582484 100644
--- a/subcmds/download.py
+++ b/subcmds/download.py
@@ -48,7 +48,7 @@
                  dest='ffonly', action='store_true',
                  help="force fast-forward merge")
 
-  def _ParseChangeIds(self, args):
+  def _ParseChangeIds(self, opt, args):
     if not args:
       self.Usage()
 
@@ -77,7 +77,7 @@
                 ps_id = max(int(match.group(1)), ps_id)
         to_get.append((project, chg_id, ps_id))
       else:
-        projects = self.GetProjects([a])
+        projects = self.GetProjects([a], all_manifests=not opt.this_manifest_only)
         if len(projects) > 1:
           # If the cwd is one of the projects, assume they want that.
           try:
@@ -88,8 +88,8 @@
             print('error: %s matches too many projects; please re-run inside '
                   'the project checkout.' % (a,), file=sys.stderr)
             for project in projects:
-              print('  %s/ @ %s' % (project.relpath, project.revisionExpr),
-                    file=sys.stderr)
+              print('  %s/ @ %s' % (project.RelPath(local=opt.this_manifest_only),
+                                    project.revisionExpr), file=sys.stderr)
             sys.exit(1)
         else:
           project = projects[0]
@@ -105,7 +105,7 @@
         self.OptionParser.error('-x and --ff are mutually exclusive options')
 
   def Execute(self, opt, args):
-    for project, change_id, ps_id in self._ParseChangeIds(args):
+    for project, change_id, ps_id in self._ParseChangeIds(opt, args):
       dl = project.DownloadPatchSet(change_id, ps_id)
       if not dl:
         print('[%s] change %d/%d not found'
diff --git a/subcmds/forall.py b/subcmds/forall.py
index 7c1dea9..cc578b5 100644
--- a/subcmds/forall.py
+++ b/subcmds/forall.py
@@ -168,6 +168,7 @@
 
   def Execute(self, opt, args):
     cmd = [opt.command[0]]
+    all_trees = not opt.this_manifest_only
 
     shell = True
     if re.compile(r'^[a-z0-9A-Z_/\.-]+$').match(cmd[0]):
@@ -213,11 +214,11 @@
       self.manifest.Override(smart_sync_manifest_path)
 
     if opt.regex:
-      projects = self.FindProjects(args)
+      projects = self.FindProjects(args, all_manifests=all_trees)
     elif opt.inverse_regex:
-      projects = self.FindProjects(args, inverse=True)
+      projects = self.FindProjects(args, inverse=True, all_manifests=all_trees)
     else:
-      projects = self.GetProjects(args, groups=opt.groups)
+      projects = self.GetProjects(args, groups=opt.groups, all_manifests=all_trees)
 
     os.environ['REPO_COUNT'] = str(len(projects))
 
@@ -290,6 +291,7 @@
 
   setenv('REPO_PROJECT', project.name)
   setenv('REPO_PATH', project.relpath)
+  setenv('REPO_OUTERPATH', project.RelPath(local=opt.this_manifest_only))
   setenv('REPO_REMOTE', project.remote.name)
   try:
     # If we aren't in a fully synced state and we don't have the ref the manifest
@@ -320,7 +322,7 @@
     output = ''
     if ((opt.project_header and opt.verbose)
             or not opt.project_header):
-      output = 'skipping %s/' % project.relpath
+      output = 'skipping %s/' % project.RelPath(local=opt.this_manifest_only)
     return (1, output)
 
   if opt.verbose:
@@ -344,7 +346,7 @@
       if mirror:
         project_header_path = project.name
       else:
-        project_header_path = project.relpath
+        project_header_path = project.RelPath(local=opt.this_manifest_only)
       out.project('project %s/' % project_header_path)
       out.nl()
       buf.write(output)
diff --git a/subcmds/gitc_init.py b/subcmds/gitc_init.py
index e705b61..1d81baf 100644
--- a/subcmds/gitc_init.py
+++ b/subcmds/gitc_init.py
@@ -24,6 +24,7 @@
 
 class GitcInit(init.Init, GitcAvailableCommand):
   COMMON = True
+  MULTI_MANIFEST_SUPPORT = False
   helpSummary = "Initialize a GITC Client."
   helpUsage = """
 %prog [options] [client name]
diff --git a/subcmds/grep.py b/subcmds/grep.py
index 8ac4ba1..93c9ae5 100644
--- a/subcmds/grep.py
+++ b/subcmds/grep.py
@@ -172,15 +172,16 @@
     return (project, p.Wait(), p.stdout, p.stderr)
 
   @staticmethod
-  def _ProcessResults(full_name, have_rev, _pool, out, results):
+  def _ProcessResults(full_name, have_rev, opt, _pool, out, results):
     git_failed = False
     bad_rev = False
     have_match = False
+    _RelPath = lambda p: p.RelPath(local=opt.this_manifest_only)
 
     for project, rc, stdout, stderr in results:
       if rc < 0:
         git_failed = True
-        out.project('--- project %s ---' % project.relpath)
+        out.project('--- project %s ---' % _RelPath(project))
         out.nl()
         out.fail('%s', stderr)
         out.nl()
@@ -192,7 +193,7 @@
           if have_rev and 'fatal: ambiguous argument' in stderr:
             bad_rev = True
           else:
-            out.project('--- project %s ---' % project.relpath)
+            out.project('--- project %s ---' % _RelPath(project))
             out.nl()
             out.fail('%s', stderr.strip())
             out.nl()
@@ -208,13 +209,13 @@
           rev, line = line.split(':', 1)
           out.write("%s", rev)
           out.write(':')
-          out.project(project.relpath)
+          out.project(_RelPath(project))
           out.write('/')
           out.write("%s", line)
           out.nl()
       elif full_name:
         for line in r:
-          out.project(project.relpath)
+          out.project(_RelPath(project))
           out.write('/')
           out.write("%s", line)
           out.nl()
@@ -239,7 +240,7 @@
       cmd_argv.append(args[0])
       args = args[1:]
 
-    projects = self.GetProjects(args)
+    projects = self.GetProjects(args, all_manifests=not opt.this_manifest_only)
 
     full_name = False
     if len(projects) > 1:
@@ -259,7 +260,7 @@
         opt.jobs,
         functools.partial(self._ExecuteOne, cmd_argv),
         projects,
-        callback=functools.partial(self._ProcessResults, full_name, have_rev),
+        callback=functools.partial(self._ProcessResults, full_name, have_rev, opt),
         output=out,
         ordered=True)
 
diff --git a/subcmds/info.py b/subcmds/info.py
index 6c1246e..4bedf9d 100644
--- a/subcmds/info.py
+++ b/subcmds/info.py
@@ -61,6 +61,8 @@
 
     self.opt = opt
 
+    if not opt.this_manifest_only:
+      self.manifest = self.manifest.outer_client
     manifestConfig = self.manifest.manifestProject.config
     mergeBranch = manifestConfig.GetBranch("default").merge
     manifestGroups = (manifestConfig.GetString('manifest.groups')
@@ -80,17 +82,17 @@
     self.printSeparator()
 
     if not opt.overview:
-      self.printDiffInfo(args)
+      self._printDiffInfo(opt, args)
     else:
-      self.printCommitOverview(args)
+      self._printCommitOverview(opt, args)
 
   def printSeparator(self):
     self.text("----------------------------")
     self.out.nl()
 
-  def printDiffInfo(self, args):
+  def _printDiffInfo(self, opt, args):
     # We let exceptions bubble up to main as they'll be well structured.
-    projs = self.GetProjects(args)
+    projs = self.GetProjects(args, all_manifests=not opt.this_manifest_only)
 
     for p in projs:
       self.heading("Project: ")
@@ -179,9 +181,9 @@
       self.text(" ".join(split[1:]))
       self.out.nl()
 
-  def printCommitOverview(self, args):
+  def _printCommitOverview(self, opt, args):
     all_branches = []
-    for project in self.GetProjects(args):
+    for project in self.GetProjects(args, all_manifests=not opt.this_manifest_only):
       br = [project.GetUploadableBranch(x)
             for x in project.GetBranches()]
       br = [x for x in br if x]
@@ -200,7 +202,7 @@
       if project != branch.project:
         project = branch.project
         self.out.nl()
-        self.headtext(project.relpath)
+        self.headtext(project.RelPath(local=opt.this_manifest_only))
         self.out.nl()
 
       commits = branch.commits
diff --git a/subcmds/init.py b/subcmds/init.py
index 32c85f7..b9775a3 100644
--- a/subcmds/init.py
+++ b/subcmds/init.py
@@ -32,6 +32,7 @@
 
 class Init(InteractiveCommand, MirrorSafeCommand):
   COMMON = True
+  MULTI_MANIFEST_SUPPORT = False
   helpSummary = "Initialize a repo client checkout in the current directory"
   helpUsage = """
 %prog [options] [manifest url]
@@ -90,6 +91,17 @@
 
   def _Options(self, p, gitc_init=False):
     Wrapper().InitParser(p, gitc_init=gitc_init)
+    m = p.add_option_group('Multi-manifest')
+    m.add_option('--outer-manifest', action='store_true',
+                 help='operate starting at the outermost manifest')
+    m.add_option('--no-outer-manifest', dest='outer_manifest',
+                 action='store_false', default=None,
+                 help='do not operate on outer manifests')
+    m.add_option('--this-manifest-only', action='store_true', default=None,
+                 help='only operate on this (sub)manifest')
+    m.add_option('--no-this-manifest-only', '--all-manifests',
+                 dest='this_manifest_only', action='store_false',
+                 help='operate on this manifest and its submanifests')
 
   def _RegisteredEnvironmentOptions(self):
     return {'REPO_MANIFEST_URL': 'manifest_url',
diff --git a/subcmds/list.py b/subcmds/list.py
index 6adf85b..ad8036e 100644
--- a/subcmds/list.py
+++ b/subcmds/list.py
@@ -77,16 +77,17 @@
       args: Positional args.  Can be a list of projects to list, or empty.
     """
     if not opt.regex:
-      projects = self.GetProjects(args, groups=opt.groups, missing_ok=opt.all)
+      projects = self.GetProjects(args, groups=opt.groups, missing_ok=opt.all,
+                                  all_manifests=not opt.this_manifest_only)
     else:
-      projects = self.FindProjects(args)
+      projects = self.FindProjects(args, all_manifests=not opt.this_manifest_only)
 
     def _getpath(x):
       if opt.fullpath:
         return x.worktree
       if opt.relative_to:
         return os.path.relpath(x.worktree, opt.relative_to)
-      return x.relpath
+      return x.RelPath(local=opt.this_manifest_only)
 
     lines = []
     for project in projects:
diff --git a/subcmds/manifest.py b/subcmds/manifest.py
index 0fbdeac..08905cb 100644
--- a/subcmds/manifest.py
+++ b/subcmds/manifest.py
@@ -15,6 +15,7 @@
 import json
 import os
 import sys
+import optparse
 
 from command import PagedCommand
 
@@ -75,7 +76,7 @@
     p.add_option('-o', '--output-file',
                  dest='output_file',
                  default='-',
-                 help='file to save the manifest to',
+                 help='file to save the manifest to. (Filename prefix for multi-tree.)',
                  metavar='-|NAME.xml')
 
   def _Output(self, opt):
@@ -83,36 +84,45 @@
     if opt.manifest_name:
       self.manifest.Override(opt.manifest_name, False)
 
-    if opt.output_file == '-':
-      fd = sys.stdout
-    else:
-      fd = open(opt.output_file, 'w')
+    for manifest in self.ManifestList(opt):
+      output_file = opt.output_file
+      if output_file == '-':
+        fd = sys.stdout
+      else:
+        if manifest.path_prefix:
+          output_file = f'{opt.output_file}:{manifest.path_prefix.replace("/", "%2f")}'
+        fd = open(output_file, 'w')
 
-    self.manifest.SetUseLocalManifests(not opt.ignore_local_manifests)
+      manifest.SetUseLocalManifests(not opt.ignore_local_manifests)
 
-    if opt.json:
-      print('warning: --json is experimental!', file=sys.stderr)
-      doc = self.manifest.ToDict(peg_rev=opt.peg_rev,
-                                 peg_rev_upstream=opt.peg_rev_upstream,
-                                 peg_rev_dest_branch=opt.peg_rev_dest_branch)
+      if opt.json:
+        print('warning: --json is experimental!', file=sys.stderr)
+        doc = manifest.ToDict(peg_rev=opt.peg_rev,
+                                   peg_rev_upstream=opt.peg_rev_upstream,
+                                   peg_rev_dest_branch=opt.peg_rev_dest_branch)
 
-      json_settings = {
-          # JSON style guide says Uunicode characters are fully allowed.
-          'ensure_ascii': False,
-          # We use 2 space indent to match JSON style guide.
-          'indent': 2 if opt.pretty else None,
-          'separators': (',', ': ') if opt.pretty else (',', ':'),
-          'sort_keys': True,
-      }
-      fd.write(json.dumps(doc, **json_settings))
-    else:
-      self.manifest.Save(fd,
-                         peg_rev=opt.peg_rev,
-                         peg_rev_upstream=opt.peg_rev_upstream,
-                         peg_rev_dest_branch=opt.peg_rev_dest_branch)
-    fd.close()
-    if opt.output_file != '-':
-      print('Saved manifest to %s' % opt.output_file, file=sys.stderr)
+        json_settings = {
+            # JSON style guide says Uunicode characters are fully allowed.
+            'ensure_ascii': False,
+            # We use 2 space indent to match JSON style guide.
+            'indent': 2 if opt.pretty else None,
+            'separators': (',', ': ') if opt.pretty else (',', ':'),
+            'sort_keys': True,
+        }
+        fd.write(json.dumps(doc, **json_settings))
+      else:
+        manifest.Save(fd,
+                      peg_rev=opt.peg_rev,
+                      peg_rev_upstream=opt.peg_rev_upstream,
+                      peg_rev_dest_branch=opt.peg_rev_dest_branch)
+      if output_file != '-':
+        fd.close()
+        if manifest.path_prefix:
+          print(f'Saved {manifest.path_prefix} submanifest to {output_file}',
+                file=sys.stderr)
+        else:
+          print(f'Saved manifest to {output_file}', file=sys.stderr)
+
 
   def ValidateOptions(self, opt, args):
     if args:
diff --git a/subcmds/overview.py b/subcmds/overview.py
index 63f5a79..11dba95 100644
--- a/subcmds/overview.py
+++ b/subcmds/overview.py
@@ -47,7 +47,7 @@
 
   def Execute(self, opt, args):
     all_branches = []
-    for project in self.GetProjects(args):
+    for project in self.GetProjects(args, all_manifests=not opt.this_manifest_only):
       br = [project.GetUploadableBranch(x)
             for x in project.GetBranches()]
       br = [x for x in br if x]
@@ -76,7 +76,7 @@
       if project != branch.project:
         project = branch.project
         out.nl()
-        out.project('project %s/' % project.relpath)
+        out.project('project %s/' % project.RelPath(local=opt.this_manifest_only))
         out.nl()
 
       commits = branch.commits
diff --git a/subcmds/prune.py b/subcmds/prune.py
index 584ee7e..251acca 100644
--- a/subcmds/prune.py
+++ b/subcmds/prune.py
@@ -31,7 +31,7 @@
     return project.PruneHeads()
 
   def Execute(self, opt, args):
-    projects = self.GetProjects(args)
+    projects = self.GetProjects(args, all_manifests=not opt.this_manifest_only)
 
     # NB: Should be able to refactor this module to display summary as results
     # come back from children.
@@ -63,7 +63,7 @@
       if project != branch.project:
         project = branch.project
         out.nl()
-        out.project('project %s/' % project.relpath)
+        out.project('project %s/' % project.RelPath(local=opt.this_manifest_only))
         out.nl()
 
       print('%s %-33s ' % (
diff --git a/subcmds/rebase.py b/subcmds/rebase.py
index 7c53eb7..3d1a63e 100644
--- a/subcmds/rebase.py
+++ b/subcmds/rebase.py
@@ -69,7 +69,7 @@
                       'consistent if you previously synced to a manifest)')
 
   def Execute(self, opt, args):
-    all_projects = self.GetProjects(args)
+    all_projects = self.GetProjects(args, all_manifests=not opt.this_manifest_only)
     one_project = len(all_projects) == 1
 
     if opt.interactive and not one_project:
@@ -98,6 +98,7 @@
     config = self.manifest.manifestProject.config
     out = RebaseColoring(config)
     out.redirect(sys.stdout)
+    _RelPath = lambda p: p.RelPath(local=opt.this_manifest_only)
 
     ret = 0
     for project in all_projects:
@@ -107,7 +108,7 @@
       cb = project.CurrentBranch
       if not cb:
         if one_project:
-          print("error: project %s has a detached HEAD" % project.relpath,
+          print("error: project %s has a detached HEAD" % _RelPath(project),
                 file=sys.stderr)
           return 1
         # ignore branches with detatched HEADs
@@ -117,7 +118,7 @@
       if not upbranch.LocalMerge:
         if one_project:
           print("error: project %s does not track any remote branches"
-                % project.relpath, file=sys.stderr)
+                % _RelPath(project), file=sys.stderr)
           return 1
         # ignore branches without remotes
         continue
@@ -130,7 +131,7 @@
       args.append(upbranch.LocalMerge)
 
       out.project('project %s: rebasing %s -> %s',
-                  project.relpath, cb, upbranch.LocalMerge)
+                  _RelPath(project), cb, upbranch.LocalMerge)
       out.nl()
       out.flush()
 
diff --git a/subcmds/stage.py b/subcmds/stage.py
index 0389a4f..5f17cb6 100644
--- a/subcmds/stage.py
+++ b/subcmds/stage.py
@@ -50,7 +50,9 @@
       self.Usage()
 
   def _Interactive(self, opt, args):
-    all_projects = [p for p in self.GetProjects(args) if p.IsDirty()]
+    all_projects = [
+        p for p in self.GetProjects(args, all_manifests=not opt.this_manifest_only)
+        if p.IsDirty()]
     if not all_projects:
       print('no projects have uncommitted modifications', file=sys.stderr)
       return
@@ -62,7 +64,8 @@
 
       for i in range(len(all_projects)):
         project = all_projects[i]
-        out.write('%3d:    %s', i + 1, project.relpath + '/')
+        out.write('%3d:    %s', i + 1,
+                  project.RelPath(local=opt.this_manifest_only) + '/')
         out.nl()
       out.nl()
 
@@ -99,7 +102,9 @@
           _AddI(all_projects[a_index - 1])
           continue
 
-      projects = [p for p in all_projects if a in [p.name, p.relpath]]
+      projects = [
+          p for p in all_projects
+          if a in [p.name, p.RelPath(local=opt.this_manifest_only)]]
       if len(projects) == 1:
         _AddI(projects[0])
         continue
diff --git a/subcmds/start.py b/subcmds/start.py
index 2addaf2..809df96 100644
--- a/subcmds/start.py
+++ b/subcmds/start.py
@@ -84,7 +84,8 @@
         projects = ['.']  # start it in the local project by default
 
     all_projects = self.GetProjects(projects,
-                                    missing_ok=bool(self.gitc_manifest))
+                                    missing_ok=bool(self.gitc_manifest),
+                                    all_manifests=not opt.this_manifest_only)
 
     # This must happen after we find all_projects, since GetProjects may need
     # the local directory, which will disappear once we save the GITC manifest.
@@ -137,6 +138,6 @@
 
     if err:
       for p in err:
-        print("error: %s/: cannot start %s" % (p.relpath, nb),
+        print("error: %s/: cannot start %s" % (p.RelPath(local=opt.this_manifest_only), nb),
               file=sys.stderr)
       sys.exit(1)
diff --git a/subcmds/status.py b/subcmds/status.py
index 5b66954..0aa4200 100644
--- a/subcmds/status.py
+++ b/subcmds/status.py
@@ -117,7 +117,7 @@
       outstring.append(''.join([status_header, item, '/']))
 
   def Execute(self, opt, args):
-    all_projects = self.GetProjects(args)
+    all_projects = self.GetProjects(args, all_manifests=not opt.this_manifest_only)
 
     def _ProcessResults(_pool, _output, results):
       ret = 0
@@ -141,9 +141,10 @@
     if opt.orphans:
       proj_dirs = set()
       proj_dirs_parents = set()
-      for project in self.GetProjects(None, missing_ok=True):
-        proj_dirs.add(project.relpath)
-        (head, _tail) = os.path.split(project.relpath)
+      for project in self.GetProjects(None, missing_ok=True, all_manifests=not opt.this_manifest_only):
+        relpath = project.RelPath(local=opt.this_manifest_only)
+        proj_dirs.add(relpath)
+        (head, _tail) = os.path.split(relpath)
         while head != "":
           proj_dirs_parents.add(head)
           (head, _tail) = os.path.split(head)
diff --git a/subcmds/sync.py b/subcmds/sync.py
index 707c5bb..f5584dc 100644
--- a/subcmds/sync.py
+++ b/subcmds/sync.py
@@ -66,6 +66,7 @@
 class Sync(Command, MirrorSafeCommand):
   jobs = 1
   COMMON = True
+  MULTI_MANIFEST_SUPPORT = False
   helpSummary = "Update working tree to the latest revision"
   helpUsage = """
 %prog [<project>...]
@@ -704,7 +705,7 @@
       if project.relpath:
         new_project_paths.append(project.relpath)
     file_name = 'project.list'
-    file_path = os.path.join(self.repodir, file_name)
+    file_path = os.path.join(self.manifest.subdir, file_name)
     old_project_paths = []
 
     if os.path.exists(file_path):
@@ -760,7 +761,7 @@
     }
 
     copylinkfile_name = 'copy-link-files.json'
-    copylinkfile_path = os.path.join(self.manifest.repodir, copylinkfile_name)
+    copylinkfile_path = os.path.join(self.manifest.subdir, copylinkfile_name)
     old_copylinkfile_paths = {}
 
     if os.path.exists(copylinkfile_path):
@@ -932,6 +933,9 @@
     if opt.prune is None:
       opt.prune = True
 
+    if self.manifest.is_multimanifest and not opt.this_manifest_only and args:
+      self.OptionParser.error('partial syncs must use --this-manifest-only')
+
   def Execute(self, opt, args):
     if opt.jobs:
       self.jobs = opt.jobs
diff --git a/subcmds/upload.py b/subcmds/upload.py
index c48deab..ef3d8e9 100644
--- a/subcmds/upload.py
+++ b/subcmds/upload.py
@@ -226,7 +226,8 @@
 
       destination = opt.dest_branch or project.dest_branch or project.revisionExpr
       print('Upload project %s/ to remote branch %s%s:' %
-            (project.relpath, destination, ' (private)' if opt.private else ''))
+            (project.RelPath(local=opt.this_manifest_only), destination,
+             ' (private)' if opt.private else ''))
       print('  branch %s (%2d commit%s, %s):' % (
           name,
           len(commit_list),
@@ -262,7 +263,7 @@
     script.append('# Uncomment the branches to upload:')
     for project, avail in pending:
       script.append('#')
-      script.append('# project %s/:' % project.relpath)
+      script.append('# project %s/:' % project.RelPath(local=opt.this_manifest_only))
 
       b = {}
       for branch in avail:
@@ -285,7 +286,7 @@
           script.append('#         %s' % commit)
         b[name] = branch
 
-      projects[project.relpath] = project
+      projects[project.RelPath(local=opt.this_manifest_only)] = project
       branches[project.name] = b
     script.append('')
 
@@ -313,7 +314,7 @@
           _die('project for branch %s not in script', name)
         branch = branches[project.name].get(name)
         if not branch:
-          _die('branch %s not in %s', name, project.relpath)
+          _die('branch %s not in %s', name, project.RelPath(local=opt.this_manifest_only))
         todo.append(branch)
     if not todo:
       _die("nothing uncommented for upload")
@@ -481,7 +482,7 @@
           else:
             fmt = '\n       (%s)'
           print(('[FAILED] %-15s %-15s' + fmt) % (
-              branch.project.relpath + '/',
+              branch.project.RelPath(local=opt.this_manifest_only) + '/',
               branch.name,
               str(branch.error)),
               file=sys.stderr)
@@ -490,7 +491,7 @@
     for branch in todo:
       if branch.uploaded:
         print('[OK    ] %-15s %s' % (
-            branch.project.relpath + '/',
+            branch.project.RelPath(local=opt.this_manifest_only) + '/',
             branch.name),
             file=sys.stderr)
 
@@ -524,7 +525,7 @@
     return (project, avail)
 
   def Execute(self, opt, args):
-    projects = self.GetProjects(args)
+    projects = self.GetProjects(args, all_manifests=not opt.this_manifest_only)
 
     def _ProcessResults(_pool, _out, results):
       pending = []
@@ -534,7 +535,8 @@
           print('repo: error: %s: Unable to upload branch "%s". '
                 'You might be able to fix the branch by running:\n'
                 '  git branch --set-upstream-to m/%s' %
-                (project.relpath, project.CurrentBranch, self.manifest.branch),
+                (project.RelPath(local=opt.this_manifest_only), project.CurrentBranch,
+                 project.manifest.branch),
                 file=sys.stderr)
         elif avail:
           pending.append(result)
@@ -554,15 +556,23 @@
               (opt.branch,), file=sys.stderr)
       return 1
 
-    pending_proj_names = [project.name for (project, available) in pending]
-    pending_worktrees = [project.worktree for (project, available) in pending]
-    hook = RepoHook.FromSubcmd(
-        hook_type='pre-upload', manifest=self.manifest,
-        opt=opt, abort_if_user_denies=True)
-    if not hook.Run(
-        project_list=pending_proj_names,
-        worktree_list=pending_worktrees):
-      return 1
+    manifests = {project.manifest.topdir: project.manifest
+                 for (project, available) in pending}
+    ret = 0
+    for manifest in manifests.values():
+      pending_proj_names = [project.name for (project, available) in pending
+                            if project.manifest.topdir == manifest.topdir]
+      pending_worktrees = [project.worktree for (project, available) in pending
+                           if project.manifest.topdir == manifest.topdir]
+      hook = RepoHook.FromSubcmd(
+          hook_type='pre-upload', manifest=manifest,
+          opt=opt, abort_if_user_denies=True)
+      if not hook.Run(
+          project_list=pending_proj_names,
+          worktree_list=pending_worktrees):
+        ret = 1
+    if ret:
+      return ret
 
     reviewers = _SplitEmails(opt.reviewers) if opt.reviewers else []
     cc = _SplitEmails(opt.cc) if opt.cc else []