project: pass --no-filter and --no-tags in DownloadPatchSet

In partial clone checkouts (e.g. --filter=blob:none or blob:limit=10k),
DownloadPatchSet previously invoked `git fetch <remote> <ref>` without
explicit filter overrides. Consequently, git fetch applied the
configured partial clone filter from the remote and omitted the blobs
for the incoming change.

When repo download subsequently checks out, cherry-picks, or merges the
downloaded commit, Git discovers that the required blobs are missing
locally and triggers on-demand unadvertised object fetches to the
promisor remote (POST /git-upload-pack with `want <blob_sha>`). On large
repositories, server-side reachability validation for unadvertised
objects can result in expensive graph traversals, proxy/gateway timeouts
(such as HTTP 502 or connection drops), and client fetch failures:
  fatal: expected 'packfile'
  fatal: could not fetch <sha> from promisor remote

Pass --no-filter (when Git >= 2.17.0) and --no-tags when fetching patch
sets in DownloadPatchSet. Because the change ref is an advertised ref
tip, fetching with --no-filter allows the server to generate a complete
packfile containing all required blobs upfront without requiring
unadvertised object reachability checks. All blobs are present in the
local object database prior to checkout or cherry-pick, completely
preventing on-demand promisor queries.

TAG=agy
CONV=bdc489e3-b20b-4ef3-b32e-549dbe21fc6c

Google-Bug-Id: b/532920848
Test: pytest tests/test_project.py
Change-Id: Ic6118e4edeb754f0467e0580fd1d0965a6553153
Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/625621
Reviewed-by: Gavin Mak <gavinmak@google.com>
Tested-by: Sam Saccone <samccone@google.com>
Commit-Queue: Sam Saccone <samccone@google.com>
diff --git a/project.py b/project.py
index 2d75d34..68986d4 100644
--- a/project.py
+++ b/project.py
@@ -2218,7 +2218,11 @@
         """Download a single patch set of a single change to FETCH_HEAD."""
         remote = self.GetRemote()
 
-        cmd = ["fetch", remote.name]
+        cmd = ["fetch"]
+        if git_require((2, 17, 0)):
+            cmd.append("--no-filter")
+        cmd.append("--no-tags")
+        cmd.append(remote.name)
         cmd.append(
             "refs/changes/%2.2d/%d/%d" % (change_id % 100, change_id, patch_id)
         )
diff --git a/tests/test_project.py b/tests/test_project.py
index 764fbc4..f82e1f6 100644
--- a/tests/test_project.py
+++ b/tests/test_project.py
@@ -3661,3 +3661,63 @@
                 self._git(worktree, "status", "--porcelain").splitlines(),
                 [" M keep.txt", "?? junk"],
             )
+
+
+class DownloadPatchSetTests(unittest.TestCase):
+    """Tests for Project.DownloadPatchSet."""
+
+    def setUp(self) -> None:
+        self.proj = mock.MagicMock()
+        self.proj.GetRevisionId.return_value = "base_rev_123"
+        self.proj.bare_git.rev_parse.return_value = "commit_sha_456"
+        mock_remote = mock.MagicMock()
+        mock_remote.name = "origin"
+        self.proj.GetRemote.return_value = mock_remote
+
+    def test_download_patch_set_with_no_filter_and_no_tags(self) -> None:
+        """Test DownloadPatchSet includes --no-filter and --no-tags."""
+        with mock.patch(
+            "project.git_require", return_value=True
+        ) as mock_git_require:
+            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
+
+                dl = project.Project.DownloadPatchSet(self.proj, 12345, 2)
+
+                mock_git_require.assert_called_once_with((2, 17, 0))
+                mock_git_cmd.assert_called_once_with(
+                    self.proj,
+                    [
+                        "fetch",
+                        "--no-filter",
+                        "--no-tags",
+                        "origin",
+                        "refs/changes/45/12345/2",
+                    ],
+                    bare=True,
+                    verify_command=True,
+                )
+                self.assertEqual(dl.change_id, 12345)
+                self.assertEqual(dl.ps_id, 2)
+                self.assertEqual(dl.commit, "commit_sha_456")
+
+    def test_download_patch_set_legacy_git_omits_no_filter(self) -> None:
+        """Test DownloadPatchSet omits --no-filter on git < 2.17."""
+        with mock.patch("project.git_require", return_value=False):
+            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
+
+                dl = project.Project.DownloadPatchSet(self.proj, 54321, 1)
+
+                mock_git_cmd.assert_called_once_with(
+                    self.proj,
+                    ["fetch", "--no-tags", "origin", "refs/changes/21/54321/1"],
+                    bare=True,
+                    verify_command=True,
+                )
+                self.assertEqual(dl.change_id, 54321)
+                self.assertEqual(dl.ps_id, 1)