project: preserve -c optimization when revision is a SHA-1

Avoid disabling --current-branch when syncing a SHA-1 revision without
an explicit project upstream (e.g., smart tags). Resolve a fallback
upstream from dest-branch or manifest defaults so -c only fetches the
target branch.

Bug: 541240657
Change-Id: Ib44b6a732131210e1ec3a3136747d1a19bc5aa18
Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/614762
Reviewed-by: Brian Gan <brgan@google.com>
Tested-by: Gavin Mak <gavinmak@google.com>
Commit-Queue: Gavin Mak <gavinmak@google.com>
diff --git a/project.py b/project.py
index 45860d2..7186e58 100644
--- a/project.py
+++ b/project.py
@@ -2857,6 +2857,18 @@
 
         return True
 
+    def _GetUpstreamFallback(self) -> Optional[str]:
+        """Resolve a fallback upstream ref when revisionExpr is a SHA-1."""
+        for cand in (
+            self.dest_branch,
+            self.manifest.default.upstreamExpr,
+            self.manifest.default.destBranchExpr,
+            self.manifest.default.revisionExpr,
+        ):
+            if cand and not IsId(cand):
+                return cand
+        return None
+
     def _RemoteFetch(
         self,
         name=None,
@@ -2890,14 +2902,31 @@
             current_branch_only = True
 
         is_sha1 = IsId(self.revisionExpr)
+        upstream = self.upstream
 
         if current_branch_only:
+            if is_sha1 and not depth:
+                # When syncing a specific commit and --depth is not set:
+                # * if upstream is explicitly specified and is not a sha1, fetch
+                #   only upstream as users expect only upstream to be fetch.
+                #   Note: The commit might not be in upstream in which case the
+                #   sync will fail.
+                # * otherwise, fetch all branches to make sure we end up with
+                #   the specific commit.
+                if not upstream:
+                    upstream = self._GetUpstreamFallback()
+
+                if upstream:
+                    current_branch_only = not IsId(upstream)
+                else:
+                    current_branch_only = False
+
             if self.revisionExpr.startswith(R_TAGS):
                 # This is a tag and its commit id should never change.
                 tag_name = self.revisionExpr[len(R_TAGS) :]
-            elif self.upstream and self.upstream.startswith(R_TAGS):
+            elif upstream and upstream.startswith(R_TAGS):
                 # This is a tag and its commit id should never change.
-                tag_name = self.upstream[len(R_TAGS) :]
+                tag_name = upstream[len(R_TAGS) :]
 
             if is_sha1 or tag_name is not None:
                 has_shallow = os.path.exists(
@@ -2915,18 +2944,6 @@
                             "persistent ref)" % self.name
                         )
                     return True
-            if is_sha1 and not depth:
-                # When syncing a specific commit and --depth is not set:
-                # * if upstream is explicitly specified and is not a sha1, fetch
-                #   only upstream as users expect only upstream to be fetch.
-                #   Note: The commit might not be in upstream in which case the
-                #   sync will fail.
-                # * otherwise, fetch all branches to make sure we end up with
-                #   the specific commit.
-                if self.upstream:
-                    current_branch_only = not IsId(self.upstream)
-                else:
-                    current_branch_only = False
 
         if not name:
             name = self.remote.name
@@ -3038,11 +3055,11 @@
             # Shallow checkout of a specific commit, fetch from that commit and
             # not the heads only as the commit might be deeper in the history.
             spec.append(branch)
-            if self.upstream:
-                spec.append(self.upstream)
+            if upstream:
+                spec.append(upstream)
         else:
             if is_sha1:
-                branch = self.upstream
+                branch = upstream
             if branch is not None and branch.strip():
                 if not branch.startswith("refs/"):
                     branch = R_HEADS + branch
diff --git a/tests/test_project.py b/tests/test_project.py
index 342ef6b..19da4f0 100644
--- a/tests/test_project.py
+++ b/tests/test_project.py
@@ -1045,13 +1045,20 @@
 class SyncOptimizationTests(unittest.TestCase):
     """Tests for sync optimization logic involving shallow clones."""
 
-    def _get_project(self, tempdir, depth=None):
+    def _get_project(
+        self,
+        tempdir: str,
+        depth: Optional[int] = None,
+        revisionExpr: Optional[str] = None,
+    ) -> project.Project:
+        if revisionExpr is None:
+            revisionExpr = "0123456789abcdef0123456789abcdef01234567"
         proj = _create_mock_project(
             tempdir,
             depth=depth,
             gitdir=os.path.join(tempdir, "gitdir"),
             objdir=os.path.join(tempdir, "objdir"),
-            revisionExpr="0123456789abcdef0123456789abcdef01234567",
+            revisionExpr=revisionExpr,
         )
         proj._CheckForImmutableRevision = mock.MagicMock(return_value=True)
         proj.DeleteWorktree = mock.MagicMock()
@@ -1278,6 +1285,124 @@
                 self.assertTrue(res)
                 mock_git_cmd.assert_not_called()
 
+    def test_remote_fetch_sha1_upstream_fallback(self) -> None:
+        """Test _RemoteFetch resolves upstream fallback for SHA-1 revisions."""
+        sha = "4f8a3c0000000000000000000000000000000000"
+        with utils_for_test.TempGitTree() as tempdir:
+            proj = self._get_project(tempdir, revisionExpr=sha)
+            proj._CheckForImmutableRevision.side_effect = [False, True]
+            proj.upstream = None
+            proj.dest_branch = "my-dest-branch"
+
+            mock_remote = mock.MagicMock()
+            mock_remote.name = "origin"
+
+            def _to_local(r: str) -> str:
+                if r.startswith("refs/heads/"):
+                    return "refs/remotes/origin/" + r[11:]
+                return r
+
+            mock_remote.ToLocal.side_effect = _to_local
+            mock_remote.PreConnectFetch.return_value = True
+            proj.GetRemote = mock.MagicMock(return_value=mock_remote)
+
+            with mock.patch("project.GitCommand") as mock_git_cmd:
+                mock_cmd_instance = mock.MagicMock()
+                mock_cmd_instance.Wait.return_value = 0
+                mock_git_cmd.return_value = mock_cmd_instance
+
+                res = proj._RemoteFetch(current_branch_only=True)
+
+                self.assertTrue(res)
+                mock_git_cmd.assert_called_once()
+                cmd_args = mock_git_cmd.call_args[0][1]
+                self.assertIn(
+                    "+refs/heads/my-dest-branch:"
+                    "refs/remotes/origin/my-dest-branch",
+                    cmd_args,
+                )
+                self.assertNotIn(
+                    "+refs/heads/*:refs/remotes/origin/*", cmd_args
+                )
+
+    def test_remote_fetch_sha1_manifest_default_fallback(self) -> None:
+        """Test _RemoteFetch upstream fallback from manifest defaults."""
+        sha = "4f8a3c0000000000000000000000000000000000"
+        with utils_for_test.TempGitTree() as tempdir:
+            proj = self._get_project(tempdir, revisionExpr=sha)
+            proj._CheckForImmutableRevision.side_effect = [False, True]
+            proj.upstream = None
+            proj.dest_branch = None
+            proj.manifest.default.upstreamExpr = "manifest-upstream"
+
+            mock_remote = mock.MagicMock()
+            mock_remote.name = "origin"
+
+            def _to_local(r: str) -> str:
+                if r.startswith("refs/heads/"):
+                    return "refs/remotes/origin/" + r[11:]
+                return r
+
+            mock_remote.ToLocal.side_effect = _to_local
+            mock_remote.PreConnectFetch.return_value = True
+            proj.GetRemote = mock.MagicMock(return_value=mock_remote)
+
+            with mock.patch("project.GitCommand") as mock_git_cmd:
+                mock_cmd_instance = mock.MagicMock()
+                mock_cmd_instance.Wait.return_value = 0
+                mock_git_cmd.return_value = mock_cmd_instance
+
+                res = proj._RemoteFetch(current_branch_only=True)
+
+                self.assertTrue(res)
+                mock_git_cmd.assert_called_once()
+                cmd_args = mock_git_cmd.call_args[0][1]
+                self.assertIn(
+                    "+refs/heads/manifest-upstream:"
+                    "refs/remotes/origin/manifest-upstream",
+                    cmd_args,
+                )
+                self.assertNotIn(
+                    "+refs/heads/*:refs/remotes/origin/*", cmd_args
+                )
+
+    def test_remote_fetch_sha1_tag_fallback(self) -> None:
+        """Test _RemoteFetch resolves upstream fallback to tag correctly."""
+        sha = "4f8a3c0000000000000000000000000000000000"
+        with utils_for_test.TempGitTree() as tempdir:
+            proj = self._get_project(tempdir, revisionExpr=sha)
+            proj._CheckForImmutableRevision.side_effect = [False, True]
+            proj.upstream = None
+            proj.dest_branch = "refs/tags/v1.0"
+
+            mock_remote = mock.MagicMock()
+            mock_remote.name = "origin"
+
+            def _to_local(r: str) -> str:
+                if r.startswith("refs/tags/"):
+                    return "refs/tags/" + r[10:]
+                return r
+
+            mock_remote.ToLocal.side_effect = _to_local
+            mock_remote.PreConnectFetch.return_value = True
+            proj.GetRemote = mock.MagicMock(return_value=mock_remote)
+
+            with mock.patch("project.GitCommand") as mock_git_cmd:
+                mock_cmd_instance = mock.MagicMock()
+                mock_cmd_instance.Wait.return_value = 0
+                mock_git_cmd.return_value = mock_cmd_instance
+
+                res = proj._RemoteFetch(current_branch_only=True)
+
+                self.assertTrue(res)
+                mock_git_cmd.assert_called_once()
+                cmd_args = mock_git_cmd.call_args[0][1]
+                self.assertIn("tag", cmd_args)
+                self.assertIn("v1.0", cmd_args)
+                self.assertNotIn(
+                    "+refs/heads/*:refs/remotes/origin/*", cmd_args
+                )
+
 
 class GetEnvVarsTests(unittest.TestCase):
     """Tests for GetEnvVars project environment variable generation."""