Commit Diff


commit - 26344f80565be46e3d97014f055d027ce72b5def
commit + 325de7d3731d19a8290c08a540a09024b4b1c7fd
blob - 7a5662662225293dd263bf9a0b5de7084013b048
blob + 2c61999410586dc67cd4b9d4dfabf04c8f8bd440
--- dulwich/cli.py
+++ dulwich/cli.py
@@ -275,6 +275,9 @@ class cmd_clone(Command):
             dest="filter_spec",
             type=str,
             help="git-rev-list-style object filter",
+        )
+        parser.add_option(
+            "--protocol", dest="protocol", type=int, help="Git protocol version to use"
         )
         options, args = parser.parse_args(args)
 
@@ -297,6 +300,7 @@ class cmd_clone(Command):
                 branch=options.branch,
                 refspec=options.refspec,
                 filter_spec=options.filter_spec,
+                protocol_version=options.protocol,
             )
         except GitProtocolError as e:
             print(f"{e}")
@@ -605,12 +609,14 @@ class cmd_pull(Command):
         parser.add_argument("from_location", type=str)
         parser.add_argument("refspec", type=str, nargs="*")
         parser.add_argument("--filter", type=str, nargs=1)
+        parser.add_argument("--protocol", type=int, nargs=1)
         args = parser.parse_args(args)
         porcelain.pull(
             ".",
             args.from_location or None,
             args.refspec or None,
             filter_spec=args.filter,
+            protocol_version=args.protocol_version or None,
         )
 
 
blob - 1d14db8be813ca5db392fb5a9d0017148f943716
blob + 80b203c147d755d8dc92268c9b30b1954670e04f
--- dulwich/client.py
+++ dulwich/client.py
@@ -116,6 +116,7 @@ from .protocol import (
     extract_capability_names,
     parse_capability,
     pkt_line,
+    GIT_PROTOCOL_VERSIONS,
 )
 from .refs import PEELED_TAG_SUFFIX, _import_remote_refs, read_info_refs
 from .repo import Repo
@@ -545,7 +546,7 @@ def _handle_upload_pack_head(
     wants,
     can_read,
     depth,
-    protocol_version=None,
+    protocol_version,
 ):
     """Handle the head of a 'git-upload-pack' request.
 
@@ -557,7 +558,7 @@ def _handle_upload_pack_head(
       can_read: function that returns a boolean that indicates
     whether there is extra graph data to read on proto
       depth: Depth for request
-      protocol_version: desired Git protocol version; defaults to v0
+      protocol_version: Neogiated Git protocol version.
     """
     assert isinstance(wants, list) and isinstance(wants[0], bytes)
     wantcmd = COMMAND_WANT + b" " + wants[0]
@@ -639,6 +640,7 @@ def _handle_upload_pack_tail(
       pack_data: Function to call with pack data
       progress: Optional progress reporting function
       rbufsize: Read buffer size
+      protocol_version: Neogiated Git protocol version.
     """
     pkt = proto.read_pkt_line()
     while pkt:
@@ -782,6 +784,7 @@ class GitClient:
         depth=None,
         ref_prefix=[],
         filter_spec=None,
+        protocol_version: Optional[int] = None,
     ) -> Repo:
         """Clone a repository."""
         from .refs import _set_default_branch, _set_head, _set_origin_head
@@ -827,6 +830,7 @@ class GitClient:
                 depth=depth,
                 ref_prefix=ref_prefix,
                 filter_spec=filter_spec,
+                protocol_version=protocol_version,
             )
             if origin is not None:
                 _import_remote_refs(
@@ -878,6 +882,7 @@ class GitClient:
         depth: Optional[int] = None,
         ref_prefix: Optional[List[bytes]] = [],
         filter_spec: Optional[bytes] = None,
+        protocol_version: Optional[int] = None,
     ) -> FetchPackResult:
         """Fetch into a target repository.
 
@@ -898,6 +903,8 @@ class GitClient:
           filter_spec: A git-rev-list-style object filter spec, as bytestring.
             Only used if the server supports the Git protocol-v2 'filter'
             feature, and ignored otherwise.
+          protocol_version: Desired Git protocol version. By default the highest
+            mutually supported protocol version will be used.
 
         Returns:
           Dictionary with all remote refs (not just those fetched)
@@ -935,6 +942,7 @@ class GitClient:
                 depth=depth,
                 ref_prefix=ref_prefix,
                 filter_spec=filter_spec,
+                protocol_version=protocol_version,
             )
         except BaseException:
             abort()
@@ -955,6 +963,7 @@ class GitClient:
         depth: Optional[int] = None,
         ref_prefix=[],
         filter_spec=None,
+        protocol_version: Optional[int] = None,
     ):
         """Retrieve a pack from a git smart server.
 
@@ -976,6 +985,8 @@ class GitClient:
           filter_spec: A git-rev-list-style object filter spec, as bytestring.
             Only used if the server supports the Git protocol-v2 'filter'
             feature, and ignored otherwise.
+          protocol_version: Desired Git protocol version. By default the highest
+            mutually supported protocol version will be used.
 
         Returns:
           FetchPackResult object
@@ -1133,7 +1144,7 @@ class TraditionalGitClient(GitClient):
         self._remote_path_encoding = path_encoding
         super().__init__(**kwargs)
 
-    async def _connect(self, cmd, path):
+    async def _connect(self, cmd, path, protocol_version=None):
         """Create a connection to the server.
 
         This method is abstract - concrete implementations should
@@ -1145,6 +1156,8 @@ class TraditionalGitClient(GitClient):
         Args:
           cmd: The git service name to which we should connect.
           path: The path we should pass to the service. (as bytestirng)
+          protocol_version: Desired Git protocol version. By default the highest
+            mutually supported protocol version will be used.
         """
         raise NotImplementedError
 
@@ -1252,6 +1265,7 @@ class TraditionalGitClient(GitClient):
         depth=None,
         ref_prefix=[],
         filter_spec=None,
+        protocol_version: Optional[int] = None,
     ):
         """Retrieve a pack from a git smart server.
 
@@ -1273,13 +1287,30 @@ class TraditionalGitClient(GitClient):
           filter_spec: A git-rev-list-style object filter spec, as bytestring.
             Only used if the server supports the Git protocol-v2 'filter'
             feature, and ignored otherwise.
+          protocol_version: Desired Git protocol version. By default the highest
+            mutually supported protocol version will be used.
 
         Returns:
           FetchPackResult object
 
         """
-        proto, can_read, stderr = self._connect(b"upload-pack", path)
-        self.protocol_version = negotiate_protocol_version(proto)
+        if (
+            protocol_version is not None
+            and protocol_version not in GIT_PROTOCOL_VERSIONS
+        ):
+            raise ValueError("unknown Git protocol version %d" % protocol_version)
+        proto, can_read, stderr = self._connect(b"upload-pack", path, protocol_version)
+        server_protocol_version = negotiate_protocol_version(proto)
+        if server_protocol_version not in GIT_PROTOCOL_VERSIONS:
+            raise ValueError(
+                "unknown Git protocol version %d used by server"
+                % server_protocol_version
+            )
+        if protocol_version and server_protocol_version > protocol_version:
+            raise ValueError(
+                "bad Git protocol version %d used by server" % server_protocol_version
+            )
+        self.protocol_version = server_protocol_version
         with proto:
             try:
                 if self.protocol_version == 2:
@@ -1352,11 +1383,26 @@ class TraditionalGitClient(GitClient):
             )
             return FetchPackResult(refs, symrefs, agent, new_shallow, new_unshallow)
 
-    def get_refs(self, path):
+    def get_refs(self, path, protocol_version=None):
         """Retrieve the current refs from a git smart server."""
         # stock `git ls-remote` uses upload-pack
-        proto, _, stderr = self._connect(b"upload-pack", path)
-        self.protocol_version = negotiate_protocol_version(proto)
+        if (
+            protocol_version is not None
+            and protocol_version not in GIT_PROTOCOL_VERSIONS
+        ):
+            raise ValueError("unknown Git protocol version %d" % protocol_version)
+        proto, _, stderr = self._connect(b"upload-pack", path, protocol_version)
+        server_protocol_version = negotiate_protocol_version(proto)
+        if server_protocol_version not in GIT_PROTOCOL_VERSIONS:
+            raise ValueError(
+                "unknown Git protocol version %d used by server"
+                % server_protocol_version
+            )
+        if protocol_version and server_protocol_version > protocol_version:
+            raise ValueError(
+                "bad Git protocol version %d used by server" % server_protocol_version
+            )
+        self.protocol_version = server_protocol_version
         if self.protocol_version == 2:
             server_capabilities = read_server_capabilities(proto.read_pkt_seq())
             proto.write_pkt_line(b"command=ls-refs\n")
@@ -1443,7 +1489,7 @@ class TCPGitClient(TraditionalGitClient):
             netloc += ":%d" % self._port
         return urlunsplit(("git", netloc, path, "", ""))
 
-    def _connect(self, cmd, path):
+    def _connect(self, cmd, path, protocol_version=None):
         if not isinstance(cmd, bytes):
             raise TypeError(cmd)
         if not isinstance(path, bytes):
@@ -1485,7 +1531,14 @@ class TCPGitClient(TraditionalGitClient):
         if path.startswith(b"/~"):
             path = path[1:]
         if cmd == b"upload-pack":
-            self.protocol_version = 2
+            if protocol_version is None:
+                self.protocol_version = 2
+            else:
+                self.protocol_version = protocol_version
+        else:
+            self.protocol_version = 0
+
+        if cmd == b"upload-pack" and self.protocol_version == 2:
             # Git protocol version advertisement is hidden behind two NUL bytes
             # for compatibility with older Git server implementations, which
             # would crash if something other than a "host=" header was found
@@ -1493,7 +1546,6 @@ class TCPGitClient(TraditionalGitClient):
             version_str = b"\0\0version=%d\0" % self.protocol_version
         else:
             version_str = b""
-            self.protocol_version = 0
         # TODO(jelmer): Alternative to ascii?
         proto.send_cmd(
             b"git-" + cmd, path, b"host=" + self._host.encode("ascii") + version_str
@@ -1557,7 +1609,7 @@ class SubprocessGitClient(TraditionalGitClient):
 
     git_command = None
 
-    def _connect(self, service, path):
+    def _connect(self, service, path, protocol_version=None):
         if not isinstance(service, bytes):
             raise TypeError(service)
         if isinstance(path, bytes):
@@ -1683,6 +1735,7 @@ class LocalGitClient(GitClient):
         depth=None,
         ref_prefix=[],
         filter_spec=None,
+        **kwargs,
     ):
         """Fetch into a target repository.
 
@@ -1727,6 +1780,7 @@ class LocalGitClient(GitClient):
         depth=None,
         ref_prefix: Optional[List[bytes]] = [],
         filter_spec: Optional[bytes] = None,
+        protocol_version: Optional[int] = None,
     ) -> FetchPackResult:
         """Retrieve a pack from a local on-disk repository.
 
@@ -1807,6 +1861,8 @@ class SSHVendor:
           password: Optional ssh password for login or private key
           key_filename: Optional path to private keyfile
           ssh_command: Optional SSH command
+          protocol_version: Desired Git protocol version. By default the highest
+            mutually supported protocol version will be used.
         """
         raise NotImplementedError(self.run_command)
 
@@ -1986,7 +2042,7 @@ class SSHGitClient(TraditionalGitClient):
         assert isinstance(cmd, bytes)
         return cmd
 
-    def _connect(self, cmd, path):
+    def _connect(self, cmd, path, protocol_version=None):
         if not isinstance(cmd, bytes):
             raise TypeError(cmd)
         if isinstance(path, bytes):
@@ -2214,7 +2270,12 @@ class AbstractHttpGitClient(GitClient):
         """
         raise NotImplementedError(self._http_request)
 
-    def _discover_references(self, service, base_url):
+    def _discover_references(self, service, base_url, protocol_version=None):
+        if (
+            protocol_version is not None
+            and protocol_version not in GIT_PROTOCOL_VERSIONS
+        ):
+            raise ValueError("unknown Git protocol version %d" % protocol_version)
         assert base_url[-1] == "/"
         tail = "info/refs"
         headers = {"Accept": "*/*"}
@@ -2226,8 +2287,12 @@ class AbstractHttpGitClient(GitClient):
             # we try: It responds with a Git-protocol-v1-style ref listing
             # which lacks the "001f# service=git-receive-pack" marker.
             if service == b"git-upload-pack":
-                self.protocol_version = 2
-                headers["Git-Protocol"] = "version=2"
+                if protocol_version is None:
+                    self.protocol_version = 2
+                else:
+                    self.protocol_version = protocol_version
+                if self.protocol_version == 2:
+                    headers["Git-Protocol"] = "version=2"
             else:
                 self.protocol_version = 0
         url = urljoin(base_url, tail)
@@ -2261,7 +2326,18 @@ class AbstractHttpGitClient(GitClient):
                     return server_capabilities, resp, read, proto
 
                 proto = Protocol(read, None)
-                self.protocol_version = negotiate_protocol_version(proto)
+                server_protocol_version = negotiate_protocol_version(proto)
+                if server_protocol_version not in GIT_PROTOCOL_VERSIONS:
+                    raise ValueError(
+                        "unknown Git protocol version %d used by server"
+                        % server_protocol_version
+                    )
+                if protocol_version and server_protocol_version > protocol_version:
+                    raise ValueError(
+                        "bad Git protocol version %d used by server"
+                        % server_protocol_version
+                    )
+                self.protocol_version = server_protocol_version
                 if self.protocol_version == 2:
                     server_capabilities, resp, read, proto = begin_protocol_v2(proto)
                 else:
@@ -2278,7 +2354,18 @@ class AbstractHttpGitClient(GitClient):
                         )
                     # Github sends "version 2" after sending the service name.
                     # Try to negotiate protocol version 2 again.
-                    self.protocol_version = negotiate_protocol_version(proto)
+                    server_protocol_version = negotiate_protocol_version(proto)
+                    if server_protocol_version not in GIT_PROTOCOL_VERSIONS:
+                        raise ValueError(
+                            "unknown Git protocol version %d used by server"
+                            % server_protocol_version
+                        )
+                    if protocol_version and server_protocol_version > protocol_version:
+                        raise ValueError(
+                            "bad Git protocol version %d used by server"
+                            % server_protocol_version
+                        )
+                    self.protocol_version = server_protocol_version
                     if self.protocol_version == 2:
                         server_capabilities, resp, read, proto = begin_protocol_v2(
                             proto
@@ -2393,6 +2480,7 @@ class AbstractHttpGitClient(GitClient):
         depth=None,
         ref_prefix=[],
         filter_spec=None,
+        protocol_version: Optional[int] = None,
     ):
         """Retrieve a pack from a git smart server.
 
@@ -2412,6 +2500,8 @@ class AbstractHttpGitClient(GitClient):
           filter_spec: A git-rev-list-style object filter spec, as bytestring.
             Only used if the server supports the Git protocol-v2 'filter'
             feature, and ignored otherwise.
+          protocol_version: Desired Git protocol version. By default the highest
+            mutually supported protocol version will be used.
 
         Returns:
           FetchPackResult object
@@ -2419,7 +2509,7 @@ class AbstractHttpGitClient(GitClient):
         """
         url = self._get_url(path)
         refs, server_capabilities, url = self._discover_references(
-            b"git-upload-pack", url
+            b"git-upload-pack", url, protocol_version
         )
         (
             negotiated_capabilities,
blob - c803373375d6ae2badb37ecc784255a7c3992512
blob + 5c7ee29ec81ccaccd8ba412ddaa5891cbeb5334f
--- dulwich/porcelain.py
+++ dulwich/porcelain.py
@@ -520,6 +520,7 @@ def clone(
     refspecs=None,
     refspec_encoding=DEFAULT_ENCODING,
     filter_spec=None,
+    protocol_version: Optional[int] = None,
     **kwargs,
 ):
     """Clone a local or remote git repository.
@@ -543,6 +544,8 @@ def clone(
       filter_spec: A git-rev-list-style object filter spec, as an ASCII string.
         Only used if the server supports the Git protocol-v2 'filter'
         feature, and ignored otherwise.
+      protocol_version: desired Git protocol version. By default the highest
+        mutually supported protocol version will be used.
     Returns: The new repository
     """
     if outstream is not None:
@@ -590,6 +593,7 @@ def clone(
         depth=depth,
         ref_prefix=encoded_refs,
         filter_spec=filter_spec,
+        protocol_version=protocol_version,
     )
 
 
@@ -1277,6 +1281,7 @@ def pull(
     force=False,
     refspec_encoding=DEFAULT_ENCODING,
     filter_spec=None,
+    protocol_version=None,
     **kwargs,
 ):
     """Pull from remote via dulwich.client.
@@ -1293,6 +1298,8 @@ def pull(
       filter_spec: A git-rev-list-style object filter spec, as an ASCII string.
         Only used if the server supports the Git protocol-v2 'filter'
         feature, and ignored otherwise.
+      protocol_version: desired Git protocol version. By default the highest
+        mutually supported protocol version will be used
     """
     # Open the repo
     with open_repo_closing(repo) as r:
@@ -1323,6 +1330,7 @@ def pull(
             determine_wants=determine_wants,
             ref_prefix=refspecs,
             filter_spec=filter_spec,
+            protocol_version=protocol_version,
         )
         for lh, rh, force_ref in selected_refs:
             if not force_ref and rh in r.refs:
blob - 3d25e6798867b28a1a15ccd3e785ab72231fd3f8
blob + f1f0128fcdc51776d698834cc7574b78eb9a3d98
--- dulwich/protocol.py
+++ dulwich/protocol.py
@@ -29,6 +29,20 @@ import dulwich
 from .errors import GitProtocolError, HangupException
 
 TCP_GIT_PORT = 9418
+
+# Git protocol version 0 is the original Git protocol, which lacked a
+# version number until Git protocol version 1 was introduced by Brandon
+# Williams in 2017.
+#
+# Protocol version 1 is simply the original v0 protocol with the addition of
+# a single packet line, which precedes the ref advertisement, indicating the
+# protocol version being used. This was done in preparation for protocol v2.
+#
+# Git protocol version 2 was first introduced by Brandon Williams in 2018 and
+# adds many features. See the gitprotocol-v2(5) manual page for details.
+# As of 2024, Git only implements version 2 during 'git fetch' and still uses
+# version 0 during 'git push'.
+GIT_PROTOCOL_VERSIONS = [0, 1, 2]
 
 ZERO_SHA = b"0" * 40
 
blob - d219bce0a253f7a28472a14c4acaf45864573718
blob + 4c577545c96c8286d0eceac5624dfecf0d6fdf85
--- tests/test_client.py
+++ tests/test_client.py
@@ -72,7 +72,7 @@ class DummyClient(TraditionalGitClient):
         self.write = write
         TraditionalGitClient.__init__(self)
 
-    def _connect(self, service, path):
+    def _connect(self, service, path, protocol_version=None):
         return Protocol(self.read, self.write), self.can_read, None