Bladeren bron

Merge basic http client implementation.

Jelmer Vernooij 13 jaren geleden
bovenliggende
commit
d94f9de245
4 gewijzigde bestanden met toevoegingen van 511 en 91 verwijderingen
  1. 3 0
      NEWS
  2. 330 88
      dulwich/client.py
  3. 175 0
      dulwich/tests/compat/test_client.py
  4. 3 3
      dulwich/tests/test_client.py

+ 3 - 0
NEWS

@@ -11,6 +11,9 @@
   * ``GitClient.send_pack`` now supports the 'side-band-64k' capability.
     (Jelmer Vernooij)
 
+  * ``HttpGitClient`` which supports the smart server protocol over
+    HTTP. "dumb" access is not yet supported. (Jelmer Vernooij, #373688)
+
  CHANGES
 
   * unittest2 or python >= 2.7 is now required for the testsuite.

+ 330 - 88
dulwich/client.py

@@ -21,9 +21,11 @@
 
 __docformat__ = 'restructuredText'
 
+from cStringIO import StringIO
 import select
 import socket
 import subprocess
+import urllib2
 import urlparse
 
 from dulwich.errors import (
@@ -137,20 +139,6 @@ class GitClient(object):
         if thin_packs:
             self._fetch_capabilities.append('thin-pack')
 
-    def _connect(self, cmd, path):
-        """Create a connection to the server.
-
-        This method is abstract - concrete implementations should
-        implement their own variant which connects to the server and
-        returns an initialized Protocol object with the service ready
-        for use and a can_read function which may be used to see if
-        reads would block.
-
-        :param cmd: The git service name to which we should connect.
-        :param path: The path we should pass to the service.
-        """
-        raise NotImplementedError()
-
     def _read_refs(self, proto):
         server_capabilities = None
         refs = {}
@@ -171,21 +159,111 @@ class GitClient(object):
         :param path: Repository path
         :param generate_pack_contents: Function that can return a sequence of the
             shas of the objects to upload.
-        :param progress: Optional callback called with progress updates
+        :param progress: Optional progress function
 
         :raises SendPackError: if server rejects the pack data
         :raises UpdateRefsError: if the server supports report-status
                                  and rejects ref updates
         """
-        proto, unused_can_read = self._connect('receive-pack', path)
-        old_refs, server_capabilities = self._read_refs(proto)
-        negotiated_capabilities = list(self._send_capabilities)
-        if 'report-status' not in server_capabilities:
-            negotiated_capabilities.remove('report-status')
-        new_refs = determine_wants(old_refs)
-        if not new_refs:
-            proto.write_pkt_line(None)
-            return {}
+        raise NotImplementedError(self.send_pack)
+
+    def fetch(self, path, target, determine_wants=None, progress=None):
+        """Fetch into a target repository.
+
+        :param path: Path to fetch from
+        :param target: Target repository to fetch into
+        :param determine_wants: Optional function to determine what refs
+            to fetch
+        :param progress: Optional progress function
+        :return: remote refs
+        """
+        if determine_wants is None:
+            determine_wants = target.object_store.determine_wants_all
+        f, commit = target.object_store.add_pack()
+        try:
+            return self.fetch_pack(path, determine_wants,
+                target.get_graph_walker(), f.write, progress)
+        finally:
+            commit()
+
+    def fetch_pack(self, path, determine_wants, graph_walker, pack_data,
+                   progress):
+        """Retrieve a pack from a git smart server.
+
+        :param determine_wants: Callback that returns list of commits to fetch
+        :param graph_walker: Object with next() and ack().
+        :param pack_data: Callback called for each bit of data in the pack
+        :param progress: Callback for progress reports (strings)
+        """
+        raise NotImplementedError(self.fetch_pack)
+
+    def _parse_status_report(self, proto):
+        unpack = proto.read_pkt_line().strip()
+        if unpack != 'unpack ok':
+            st = True
+            # flush remaining error data
+            while st is not None:
+                st = proto.read_pkt_line()
+            raise SendPackError(unpack)
+        statuses = []
+        errs = False
+        ref_status = proto.read_pkt_line()
+        while ref_status:
+            ref_status = ref_status.strip()
+            statuses.append(ref_status)
+            if not ref_status.startswith('ok '):
+                errs = True
+            ref_status = proto.read_pkt_line()
+
+        if errs:
+            ref_status = {}
+            ok = set()
+            for status in statuses:
+                if ' ' not in status:
+                    # malformed response, move on to the next one
+                    continue
+                status, ref = status.split(' ', 1)
+
+                if status == 'ng':
+                    if ' ' in ref:
+                        ref, status = ref.split(' ', 1)
+                else:
+                    ok.add(ref)
+                ref_status[ref] = status
+            raise UpdateRefsError('%s failed to update' %
+                                  ', '.join([ref for ref in ref_status
+                                             if ref not in ok]),
+                                  ref_status=ref_status)
+
+    def _read_side_band64k_data(self, proto, channel_callbacks):
+        """Read per-channel data.
+
+        This requires the side-band-64k capability.
+
+        :param proto: Protocol object to read from
+        :param channel_callbacks: Dictionary mapping channels to packet
+            handlers to use. None for a callback discards channel data.
+        """
+        for pkt in proto.read_pkt_seq():
+            channel = ord(pkt[0])
+            pkt = pkt[1:]
+            try:
+                cb = channel_callbacks[channel]
+            except KeyError:
+                raise AssertionError('Invalid sideband channel %d' % channel)
+            else:
+                if cb is not None:
+                    cb(pkt)
+
+    def _handle_receive_pack_head(self, proto, capabilities, old_refs, new_refs):
+        """Handle the head of a 'git-receive-pack' request.
+
+        :param proto: Protocol object to read from
+        :param capabilities: List of negotiated capabilities
+        :param old_refs: Old refs, as received from the server
+        :param new_refs: New refs
+        :return: (have, want) tuple
+        """
         want = []
         have = [x for x in old_refs.values() if not x == ZERO_SHA]
         sent_capabilities = False
@@ -199,22 +277,27 @@ class GitClient(object):
                 else:
                     proto.write_pkt_line(
                       '%s %s %s\0%s' % (old_sha1, new_sha1, refname,
-                                        ' '.join(negotiated_capabilities)))
+                                        ' '.join(capabilities)))
                     sent_capabilities = True
             if new_sha1 not in have and new_sha1 != ZERO_SHA:
                 want.append(new_sha1)
         proto.write_pkt_line(None)
-        if not want:
-            return new_refs
-        objects = generate_pack_contents(have, want)
-        entries, sha = write_pack_objects(proto.write_file(), objects)
-        if 'report-status' in negotiated_capabilities:
+        return (have, want)
+
+    def _handle_receive_pack_tail(self, proto, capabilities, progress):
+        """Handle the tail of a 'git-receive-pack' request.
+
+        :param proto: Protocol object to read from
+        :param capabilities: List of negotiated capabilities
+        :param progress: Optional progress reporting function
+        """
+        if 'report-status' in capabilities:
             report_status_parser = ReportStatusParser()
         else:
             report_status_parser = None
-        if "side-band-64k" in negotiated_capabilities:
+        if "side-band-64k" in capabilities:
             channel_callbacks = { 2: progress }
-            if 'report-status' in negotiated_capabilities:
+            if 'report-status' in capabilities:
                 channel_callbacks[1] = PktLineParser(
                     report_status_parser.handle_packet).parse
             self._read_side_band64k_data(proto, channel_callbacks)
@@ -228,46 +311,21 @@ class GitClient(object):
         data = proto.read()
         if data:
             raise SendPackError('Unexpected response %r' % data)
-        return new_refs
-
-    def fetch(self, path, target, determine_wants=None, progress=None):
-        """Fetch into a target repository.
-
-        :param path: Path to fetch from
-        :param target: Target repository to fetch into
-        :param determine_wants: Optional function to determine what refs
-            to fetch
-        :param progress: Optional progress function
-        :return: remote refs
-        """
-        if determine_wants is None:
-            determine_wants = target.object_store.determine_wants_all
-        f, commit = target.object_store.add_pack()
-        try:
-            return self.fetch_pack(path, determine_wants,
-                target.get_graph_walker(), f.write, progress)
-        finally:
-            commit()
 
-    def fetch_pack(self, path, determine_wants, graph_walker, pack_data,
-                   progress=None):
-        """Retrieve a pack from a git smart server.
+    def _handle_upload_pack_head(self, proto, capabilities, graph_walker,
+                                 wants, can_read):
+        """Handle the head of a 'git-upload-pack' request.
 
-        :param determine_wants: Callback that returns list of commits to fetch
-        :param graph_walker: Object with next() and ack().
-        :param pack_data: Callback called for each bit of data in the pack
-        :param progress: Callback for progress reports (strings)
+        :param proto: Protocol object to read from
+        :param capabilities: List of negotiated capabilities
+        :param graph_walker: GraphWalker instance to call .ack() on
+        :param wants: List of commits to fetch
+        :param can_read: function that returns a boolean that indicates
+            whether there is extra graph data to read on proto
         """
-        proto, can_read = self._connect('upload-pack', path)
-        (refs, server_capabilities) = self._read_refs(proto)
-        negotiated_capabilities = list(self._fetch_capabilities)
-        wants = determine_wants(refs)
-        if not wants:
-            proto.write_pkt_line(None)
-            return refs
         assert isinstance(wants, list) and type(wants[0]) == str
         proto.write_pkt_line('want %s %s\n' % (
-            wants[0], ' '.join(negotiated_capabilities)))
+            wants[0], ' '.join(capabilities)))
         for want in wants[1:]:
             proto.write_pkt_line('want %s\n' % want)
         proto.write_pkt_line(None)
@@ -289,6 +347,17 @@ class GitClient(object):
                             parts[2])
             have = graph_walker.next()
         proto.write_pkt_line('done\n')
+
+    def _handle_upload_pack_tail(self, proto, capabilities, graph_walker,
+                                 pack_data, progress):
+        """Handle the tail of a 'git-upload-pack' request.
+
+        :param proto: Protocol object to read from
+        :param capabilities: List of negotiated capabilities
+        :param graph_walker: GraphWalker instance to call .ack() on
+        :param pack_data: Function to call with pack data
+        :param progress: Optional progress reporting function
+        """
         pkt = proto.read_pkt_line()
         while pkt:
             parts = pkt.rstrip('\n').split(' ')
@@ -297,7 +366,7 @@ class GitClient(object):
             if len(parts) < 3 or parts[2] not in ('continue', 'common'):
                 break
             pkt = proto.read_pkt_line()
-        if "side-band-64k" in negotiated_capabilities:
+        if "side-band-64k" in capabilities:
             self._read_side_band64k_data(proto, {1: pack_data, 2: progress})
             # wait for EOF before returning
             data = proto.read()
@@ -306,30 +375,82 @@ class GitClient(object):
         else:
             # FIXME: Buffering?
             pack_data(self.read())
-        return refs
 
-    def _read_side_band64k_data(self, proto, channel_callbacks):
-        """Read per-channel data.
 
-        This requires the side-band-64k capability.
 
-        :param proto: Protocol object to read from
-        :param channel_callbacks: Dictionary mapping channels to packet
-            handlers to use. None for a callback discards channel data.
+class TraditionalGitClient(GitClient):
+    """Traditional Git client."""
+
+    def _connect(self, cmd, path):
+        """Create a connection to the server.
+
+        This method is abstract - concrete implementations should
+        implement their own variant which connects to the server and
+        returns an initialized Protocol object with the service ready
+        for use and a can_read function which may be used to see if
+        reads would block.
+
+        :param cmd: The git service name to which we should connect.
+        :param path: The path we should pass to the service.
         """
-        for pkt in proto.read_pkt_seq():
-            channel = ord(pkt[0])
-            pkt = pkt[1:]
-            try:
-                cb = channel_callbacks[channel]
-            except KeyError:
-                raise AssertionError('Invalid sideband channel %d' % channel)
-            else:
-                if cb is not None:
-                    cb(pkt)
+        raise NotImplementedError()
 
+    def send_pack(self, path, determine_wants, generate_pack_contents,
+                  progress=None):
+        """Upload a pack to a remote repository.
+
+        :param path: Repository path
+        :param generate_pack_contents: Function that can return a sequence of the
+            shas of the objects to upload.
+        :param progress: Optional callback called with progress updates
 
-class TCPGitClient(GitClient):
+        :raises SendPackError: if server rejects the pack data
+        :raises UpdateRefsError: if the server supports report-status
+                                 and rejects ref updates
+        """
+        proto, unused_can_read = self._connect('receive-pack', path)
+        old_refs, server_capabilities = self._read_refs(proto)
+        negotiated_capabilities = list(self._send_capabilities)
+        if 'report-status' not in server_capabilities:
+            negotiated_capabilities.remove('report-status')
+        new_refs = determine_wants(old_refs)
+        if not new_refs:
+            proto.write_pkt_line(None)
+            return {}
+        (have, want) = self._handle_receive_pack_head(proto,
+            negotiated_capabilities, old_refs, new_refs)
+        if not want:
+            return new_refs
+        objects = generate_pack_contents(have, want)
+        entries, sha = write_pack_objects(proto.write_file(), objects)
+        self._handle_receive_pack_tail(proto, negotiated_capabilities,
+            progress)
+        return new_refs
+
+    def fetch_pack(self, path, determine_wants, graph_walker, pack_data,
+                   progress=None):
+        """Retrieve a pack from a git smart server.
+
+        :param determine_wants: Callback that returns list of commits to fetch
+        :param graph_walker: Object with next() and ack().
+        :param pack_data: Callback called for each bit of data in the pack
+        :param progress: Callback for progress reports (strings)
+        """
+        proto, can_read = self._connect('upload-pack', path)
+        (refs, server_capabilities) = self._read_refs(proto)
+        negotiated_capabilities = list(self._fetch_capabilities)
+        wants = determine_wants(refs)
+        if not wants:
+            proto.write_pkt_line(None)
+            return refs
+        self._handle_upload_pack_head(proto, negotiated_capabilities,
+            graph_walker, wants, can_read)
+        self._handle_upload_pack_tail(proto, negotiated_capabilities,
+            graph_walker, pack_data, progress)
+        return refs
+
+
+class TCPGitClient(TraditionalGitClient):
     """A Git Client that works over TCP directly (i.e. git://)."""
 
     def __init__(self, host, port=None, *args, **kwargs):
@@ -391,7 +512,7 @@ class SubprocessWrapper(object):
         self.proc.wait()
 
 
-class SubprocessGitClient(GitClient):
+class SubprocessGitClient(TraditionalGitClient):
     """Git client that talks to a server using a subprocess."""
 
     def __init__(self, *args, **kwargs):
@@ -428,7 +549,7 @@ class SSHVendor(object):
 get_ssh_vendor = SSHVendor
 
 
-class SSHGitClient(GitClient):
+class SSHGitClient(TraditionalGitClient):
 
     def __init__(self, host, port=None, username=None, *args, **kwargs):
         self.host = host
@@ -448,6 +569,124 @@ class SSHGitClient(GitClient):
                 con.can_read)
 
 
+class HttpGitClient(GitClient):
+
+    def __init__(self, host, port=None, username=None, password=None, dumb=None, *args, **kwargs):
+        self.host = host
+        self.port = port
+        self.dumb = dumb
+        self.username = username
+        self.password = password
+        netloc = self.host
+        if self.port:
+            netloc += ":%d" % self.port
+        self.url = "http://%s/" % netloc
+        GitClient.__init__(self, *args, **kwargs)
+
+    @classmethod
+    def from_url(cls, url):
+        parsed = urlparse.urlparse(url)
+        assert parsed.scheme == 'http'
+        return cls(parsed.hostname, port=parsed.port, username=parsed.port,
+                   password=parsed.password), parsed.path
+
+    def _discover_references(self, service, url):
+        url = urlparse.urljoin(url+"/", "info/refs")
+        if not self.dumb:
+            url += "?service=%s" % service
+        req = urllib2.Request(url)
+        resp = urllib2.urlopen(req)
+        self.dumb = (not resp.info().gettype().startswith("application/x-git-"))
+        proto = Protocol(resp.read, None)
+        if not self.dumb:
+            # The first line should mention the service
+            pkts = list(proto.read_pkt_seq())
+            if pkts != [('# service=%s\n' % service)]:
+                raise ValueError("unexpected first line %r from smart server" % pkts)
+        return self._read_refs(proto)
+
+    def _smart_request(self, service, url, data):
+        url = urlparse.urljoin(url+"/", service)
+        req = urllib2.Request(url,
+            headers={"Content-Type": "application/x-%s-request" % service},
+            data=data)
+        resp = urllib2.urlopen(req)
+        if resp.getcode() != 200:
+            raise ValueError("Invalid HTTP response from server: %d" % resp.getcode())
+        if resp.info().gettype() != ("application/x-%s-result" % service):
+            raise ValueError("Invalid content-type from server: %s" % resp.info().gettype())
+        return resp
+
+    def send_pack(self, path, determine_wants, generate_pack_contents,
+                  progress=None):
+        """Upload a pack to a remote repository.
+
+        :param path: Repository path
+        :param generate_pack_contents: Function that can return a sequence of the
+            shas of the objects to upload.
+        :param progress: Optional progress function
+
+        :raises SendPackError: if server rejects the pack data
+        :raises UpdateRefsError: if the server supports report-status
+                                 and rejects ref updates
+        """
+        url = urlparse.urljoin(self.url, path)
+        old_refs, server_capabilities = self._discover_references("git-receive-pack", url)
+        negotiated_capabilities = list(self._send_capabilities)
+        new_refs = determine_wants(old_refs)
+        if not new_refs:
+            return {}
+        if self.dumb:
+            raise NotImplementedError(self.fetch_pack)
+        req_data = StringIO()
+        req_proto = Protocol(None, req_data.write)
+        (have, want) = self._handle_receive_pack_head(
+            req_proto, negotiated_capabilities, old_refs, new_refs)
+        if not want:
+            return new_refs
+        objects = generate_pack_contents(have, want)
+        entries, sha = write_pack_objects(req_proto.write_file(), objects)
+        resp = self._smart_request("git-receive-pack", url,
+            data=req_data.getvalue())
+        if resp.getcode() != 200:
+            raise ValueError("invalid http response during git-receive-pack: %d"
+                             % resp.getcode())
+        resp_proto = Protocol(resp.read, None)
+        self._handle_receive_pack_tail(resp_proto, negotiated_capabilities,
+            progress)
+        return new_refs
+
+    def fetch_pack(self, path, determine_wants, graph_walker, pack_data,
+                   progress):
+        """Retrieve a pack from a git smart server.
+
+        :param determine_wants: Callback that returns list of commits to fetch
+        :param graph_walker: Object with next() and ack().
+        :param pack_data: Callback called for each bit of data in the pack
+        :param progress: Callback for progress reports (strings)
+        """
+        url = urlparse.urljoin(self.url, path)
+        refs, server_capabilities = self._discover_references(
+            "git-upload-pack", url)
+        negotiated_capabilities = list(server_capabilities)
+        wants = determine_wants(refs)
+        if not wants:
+            return refs
+        if self.dumb:
+            raise NotImplementedError(self.send_pack)
+        req_data = StringIO()
+        req_proto = Protocol(None, req_data.write)
+        self._handle_upload_pack_head(req_proto,
+            negotiated_capabilities, graph_walker, wants,
+            lambda: False)
+        resp = self._smart_request("git-upload-pack", url,
+            data=req_data.getvalue())
+        resp_proto = Protocol(resp.read, None)
+        self._handle_upload_pack_tail(resp_proto, negotiated_capabilities,
+            graph_walker, pack_data, progress)
+        return refs
+
+
 def get_transport_and_path(uri):
     """Obtain a git client from a URI or path.
 
@@ -460,6 +699,9 @@ def get_transport_and_path(uri):
     elif parsed.scheme == 'git+ssh':
         return SSHGitClient(parsed.hostname, port=parsed.port,
                             username=parsed.username), parsed.path
+    elif parsed.scheme == 'http':
+        return HttpGitClient(parsed.hostname, port=parsed.port,
+                             username=parsed.username), parsed.path
 
     if parsed.scheme and not parsed.netloc:
         # SSH with no user@, zero or one leading slash.

+ 175 - 0
dulwich/tests/compat/test_client.py

@@ -19,11 +19,18 @@
 
 """Compatibilty tests between the Dulwich client and the cgit server."""
 
+import BaseHTTPServer
+import SimpleHTTPServer
+import copy
 import os
+import select
 import shutil
 import signal
 import subprocess
 import tempfile
+import threading
+import urllib
+import urlparse
 
 from dulwich import (
     client,
@@ -44,6 +51,9 @@ from dulwich.tests.compat.utils import (
     import_repo_to_dir,
     run_git_or_fail,
     )
+from dulwich.tests.compat.server_utils import (
+    ShutdownServerMixIn,
+    )
 
 
 class DulwichClientTestBase(object):
@@ -249,3 +259,168 @@ class DulwichSubprocessClientTest(CompatTestCase, DulwichClientTestBase):
 
     def _build_path(self, path):
         return self.gitroot + path
+
+
+class GitHTTPRequestHandler(SimpleHTTPServer.SimpleHTTPRequestHandler):
+    """HTTP Request handler that calls out to 'git http-backend'."""
+
+    # Make rfile unbuffered -- we need to read one line and then pass
+    # the rest to a subprocess, so we can't use buffered input.
+    rbufsize = 0
+
+    def do_POST(self):
+        self.run_backend()
+
+    def do_GET(self):
+        self.run_backend()
+
+    def send_head(self):
+        return self.run_backend()
+
+    def log_request(self, code='-', size='-'):
+        # Let's be quiet, the test suite is noisy enough already
+        pass
+
+    def run_backend(self):
+        """Call out to git http-backend."""
+        # Based on CGIHTTPServer.CGIHTTPRequestHandler.run_cgi:
+        # Copyright (c) 2001-2010 Python Software Foundation; All Rights Reserved
+        # Licensed under the Python Software Foundation License.
+        rest = self.path
+        # find an explicit query string, if present.
+        i = rest.rfind('?')
+        if i >= 0:
+            rest, query = rest[:i], rest[i+1:]
+        else:
+            query = ''
+
+        env = copy.deepcopy(os.environ)
+        env['SERVER_SOFTWARE'] = self.version_string()
+        env['SERVER_NAME'] = self.server.server_name
+        env['GATEWAY_INTERFACE'] = 'CGI/1.1'
+        env['SERVER_PROTOCOL'] = self.protocol_version
+        env['SERVER_PORT'] = str(self.server.server_port)
+        env['GIT_PROJECT_ROOT'] = self.server.root_path
+        env["GIT_HTTP_EXPORT_ALL"] = "1"
+        env['REQUEST_METHOD'] = self.command
+        uqrest = urllib.unquote(rest)
+        env['PATH_INFO'] = uqrest
+        env['SCRIPT_NAME'] = "/"
+        if query:
+            env['QUERY_STRING'] = query
+        host = self.address_string()
+        if host != self.client_address[0]:
+            env['REMOTE_HOST'] = host
+        env['REMOTE_ADDR'] = self.client_address[0]
+        authorization = self.headers.getheader("authorization")
+        if authorization:
+            authorization = authorization.split()
+            if len(authorization) == 2:
+                import base64, binascii
+                env['AUTH_TYPE'] = authorization[0]
+                if authorization[0].lower() == "basic":
+                    try:
+                        authorization = base64.decodestring(authorization[1])
+                    except binascii.Error:
+                        pass
+                    else:
+                        authorization = authorization.split(':')
+                        if len(authorization) == 2:
+                            env['REMOTE_USER'] = authorization[0]
+        # XXX REMOTE_IDENT
+        if self.headers.typeheader is None:
+            env['CONTENT_TYPE'] = self.headers.type
+        else:
+            env['CONTENT_TYPE'] = self.headers.typeheader
+        length = self.headers.getheader('content-length')
+        if length:
+            env['CONTENT_LENGTH'] = length
+        referer = self.headers.getheader('referer')
+        if referer:
+            env['HTTP_REFERER'] = referer
+        accept = []
+        for line in self.headers.getallmatchingheaders('accept'):
+            if line[:1] in "\t\n\r ":
+                accept.append(line.strip())
+            else:
+                accept = accept + line[7:].split(',')
+        env['HTTP_ACCEPT'] = ','.join(accept)
+        ua = self.headers.getheader('user-agent')
+        if ua:
+            env['HTTP_USER_AGENT'] = ua
+        co = filter(None, self.headers.getheaders('cookie'))
+        if co:
+            env['HTTP_COOKIE'] = ', '.join(co)
+        # XXX Other HTTP_* headers
+        # Since we're setting the env in the parent, provide empty
+        # values to override previously set values
+        for k in ('QUERY_STRING', 'REMOTE_HOST', 'CONTENT_LENGTH',
+                  'HTTP_USER_AGENT', 'HTTP_COOKIE', 'HTTP_REFERER'):
+            env.setdefault(k, "")
+
+        self.send_response(200, "Script output follows")
+
+        decoded_query = query.replace('+', ' ')
+
+        try:
+            nbytes = int(length)
+        except (TypeError, ValueError):
+            nbytes = 0
+        if self.command.lower() == "post" and nbytes > 0:
+            data = self.rfile.read(nbytes)
+        else:
+            data = None
+        # throw away additional data [see bug #427345]
+        while select.select([self.rfile._sock], [], [], 0)[0]:
+            if not self.rfile._sock.recv(1):
+                break
+        args = ['-c', 'http.uploadpack=true', '-c', 'http.receivepack=true', 'http-backend']
+        if '=' not in decoded_query:
+            args.append(decoded_query)
+        stdout = run_git_or_fail(args, input=data, env=env, stderr=subprocess.PIPE)
+        self.wfile.write(stdout)
+
+
+class HTTPGitServer(BaseHTTPServer.HTTPServer):
+
+    allow_reuse_address = True
+
+    def __init__(self, server_address, root_path):
+        BaseHTTPServer.HTTPServer.__init__(self, server_address, GitHTTPRequestHandler)
+        self.root_path = root_path
+
+    def get_url(self):
+        return 'http://%s:%s/' % (self.server_name, self.server_port)
+
+
+if not getattr(HTTPGitServer, 'shutdown', None):
+    _HTTPGitServer = HTTPGitServer
+
+    class TCPGitServer(ShutdownServerMixIn, HTTPGitServer):
+        """Subclass of HTTPGitServer that can be shut down."""
+
+        def __init__(self, *args, **kwargs):
+            # BaseServer is old-style so we have to call both __init__s
+            ShutdownServerMixIn.__init__(self)
+            _HTTPGitServer.__init__(self, *args, **kwargs)
+
+
+class DulwichHttpClientTest(CompatTestCase, DulwichClientTestBase):
+
+    def setUp(self):
+        CompatTestCase.setUp(self)
+        DulwichClientTestBase.setUp(self)
+        self._httpd = HTTPGitServer(("localhost", 0), self.gitroot)
+        self.addCleanup(self._httpd.shutdown)
+        threading.Thread(target=self._httpd.serve_forever).start()
+
+    def tearDown(self):
+        DulwichClientTestBase.tearDown(self)
+        CompatTestCase.tearDown(self)
+
+    def _client(self):
+        ret, self._path = client.HttpGitClient.from_url(self._httpd.get_url())
+        return ret
+
+    def _build_path(self, path):
+        return urlparse.urljoin(self._path.strip("/"), path.strip("/"))

+ 3 - 3
dulwich/tests/test_client.py

@@ -19,7 +19,7 @@
 from cStringIO import StringIO
 
 from dulwich.client import (
-    GitClient,
+    TraditionalGitClient,
     TCPGitClient,
     SubprocessGitClient,
     SSHGitClient,
@@ -37,13 +37,13 @@ from dulwich.protocol import (
     )
 
 
-class DummyClient(GitClient):
+class DummyClient(TraditionalGitClient):
 
     def __init__(self, can_read, read, write):
         self.can_read = can_read
         self.read = read
         self.write = write
-        GitClient.__init__(self)
+        TraditionalGitClient.__init__(self)
 
     def _connect(self, service, path):
         return Protocol(self.read, self.write), self.can_read