Add 'dest-path' to extend-project to support changing path

This allows us to move the repository to a new location in the source
tree without having to remove-project + add a new project tag.

Change-Id: I4dba6151842e57f6f2b8fe60cda260ecea68b7b4
Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/310962
Reviewed-by: Mike Frysinger <vapier@google.com>
Tested-by: Michael Kelly <mkelly@arista.com>
diff --git a/docs/manifest-format.md b/docs/manifest-format.md
index ed297ae..8e0049b 100644
--- a/docs/manifest-format.md
+++ b/docs/manifest-format.md
@@ -90,6 +90,7 @@
   <!ELEMENT extend-project EMPTY>
   <!ATTLIST extend-project name CDATA #REQUIRED>
   <!ATTLIST extend-project path CDATA #IMPLIED>
+  <!ATTLIST extend-project dest-path CDATA #IMPLIED>
   <!ATTLIST extend-project groups CDATA #IMPLIED>
   <!ATTLIST extend-project revision CDATA #IMPLIED>
   <!ATTLIST extend-project remote CDATA #IMPLIED>
@@ -337,6 +338,11 @@
 Attribute `path`: If specified, limit the change to projects checked out
 at the specified path, rather than all projects with the given name.
 
+Attribute `dest-path`: If specified, a path relative to the top directory
+of the repo client where the Git working directory for this project
+should be placed.  This is used to move a project in the checkout by
+overriding the existing `path` setting.
+
 Attribute `groups`: List of additional groups to which this project
 belongs.  Same syntax as the corresponding element of `project`.
 
diff --git a/manifest_xml.py b/manifest_xml.py
index 86f2020..3965697 100644
--- a/manifest_xml.py
+++ b/manifest_xml.py
@@ -868,6 +868,7 @@
                                    'project: %s' % name)
 
         path = node.getAttribute('path')
+        dest_path = node.getAttribute('dest-path')
         groups = node.getAttribute('groups')
         if groups:
           groups = self._ParseList(groups)
@@ -876,6 +877,10 @@
         if remote:
           remote = self._get_remote(node)
 
+        named_projects = self._projects[name]
+        if dest_path and not path and len(named_projects) > 1:
+          raise ManifestParseError('extend-project cannot use dest-path when '
+                                   'matching multiple projects: %s' % name)
         for p in self._projects[name]:
           if path and p.relpath != path:
             continue
@@ -889,6 +894,12 @@
               p.revisionId = None
           if remote:
             p.remote = remote.ToRemoteSpec(name)
+          if dest_path:
+            del self._paths[p.relpath]
+            relpath, worktree, gitdir, objdir, _ = self.GetProjectPaths(name, dest_path)
+            p.UpdatePaths(relpath, worktree, gitdir, objdir)
+            self._paths[p.relpath] = p
+
       if node.nodeName == 'repo-hooks':
         # Only one project can be the hooks project
         if repo_hooks_project is not None:
diff --git a/project.py b/project.py
index 634d88c..9ff9df0 100644
--- a/project.py
+++ b/project.py
@@ -519,13 +519,7 @@
     self.client = self.manifest = manifest
     self.name = name
     self.remote = remote
-    self.gitdir = gitdir.replace('\\', '/')
-    self.objdir = objdir.replace('\\', '/')
-    if worktree:
-      self.worktree = os.path.normpath(worktree).replace('\\', '/')
-    else:
-      self.worktree = None
-    self.relpath = relpath
+    self.UpdatePaths(relpath, worktree, gitdir, objdir)
     self.revisionExpr = revisionExpr
 
     if revisionId is None \
@@ -556,16 +550,6 @@
     self.copyfiles = []
     self.linkfiles = []
     self.annotations = []
-    self.config = GitConfig.ForRepository(gitdir=self.gitdir,
-                                          defaults=self.client.globalConfig)
-
-    if self.worktree:
-      self.work_git = self._GitGetByExec(self, bare=False, gitdir=gitdir)
-    else:
-      self.work_git = None
-    self.bare_git = self._GitGetByExec(self, bare=True, gitdir=gitdir)
-    self.bare_ref = GitRefs(gitdir)
-    self.bare_objdir = self._GitGetByExec(self, bare=True, gitdir=objdir)
     self.dest_branch = dest_branch
     self.old_revision = old_revision
 
@@ -573,6 +557,27 @@
     # project containing repo hooks.
     self.enabled_repo_hooks = []
 
+  def UpdatePaths(self, relpath, worktree, gitdir, objdir):
+    """Update paths used by this project"""
+    self.gitdir = gitdir.replace('\\', '/')
+    self.objdir = objdir.replace('\\', '/')
+    if worktree:
+      self.worktree = os.path.normpath(worktree).replace('\\', '/')
+    else:
+      self.worktree = None
+    self.relpath = relpath
+
+    self.config = GitConfig.ForRepository(gitdir=self.gitdir,
+                                          defaults=self.manifest.globalConfig)
+
+    if self.worktree:
+      self.work_git = self._GitGetByExec(self, bare=False, gitdir=self.gitdir)
+    else:
+      self.work_git = None
+    self.bare_git = self._GitGetByExec(self, bare=True, gitdir=self.gitdir)
+    self.bare_ref = GitRefs(self.gitdir)
+    self.bare_objdir = self._GitGetByExec(self, bare=True, gitdir=self.objdir)
+
   @property
   def Derived(self):
     return self.is_derived
diff --git a/tests/test_manifest_xml.py b/tests/test_manifest_xml.py
index ce42253..cb3eb85 100644
--- a/tests/test_manifest_xml.py
+++ b/tests/test_manifest_xml.py
@@ -797,3 +797,49 @@
 </manifest>
 """)
     self.assertEqual(manifest.projects, [])
+
+
+class ExtendProjectElementTests(ManifestParseTestCase):
+  """Tests for <extend-project>."""
+
+  def test_extend_project_dest_path_single_match(self):
+    manifest = self.getXmlManifest("""
+<manifest>
+  <remote name="default-remote" fetch="http://localhost" />
+  <default remote="default-remote" revision="refs/heads/main" />
+  <project name="myproject" />
+  <extend-project name="myproject" dest-path="bar" />
+</manifest>
+""")
+    self.assertEqual(len(manifest.projects), 1)
+    self.assertEqual(manifest.projects[0].relpath, 'bar')
+
+  def test_extend_project_dest_path_multi_match(self):
+    with self.assertRaises(manifest_xml.ManifestParseError):
+      manifest = self.getXmlManifest("""
+<manifest>
+  <remote name="default-remote" fetch="http://localhost" />
+  <default remote="default-remote" revision="refs/heads/main" />
+  <project name="myproject" path="x" />
+  <project name="myproject" path="y" />
+  <extend-project name="myproject" dest-path="bar" />
+</manifest>
+""")
+      manifest.projects
+
+  def test_extend_project_dest_path_multi_match_path_specified(self):
+    manifest = self.getXmlManifest("""
+<manifest>
+  <remote name="default-remote" fetch="http://localhost" />
+  <default remote="default-remote" revision="refs/heads/main" />
+  <project name="myproject" path="x" />
+  <project name="myproject" path="y" />
+  <extend-project name="myproject" path="x" dest-path="bar" />
+</manifest>
+""")
+    self.assertEqual(len(manifest.projects), 2)
+    if manifest.projects[0].relpath == 'y':
+      self.assertEqual(manifest.projects[1].relpath, 'bar')
+    else:
+      self.assertEqual(manifest.projects[0].relpath, 'bar')
+      self.assertEqual(manifest.projects[1].relpath, 'y')