diff --git a/dulwich/client.py b/dulwich/client.py index 9f5e45d2..75946ad9 100644 --- a/dulwich/client.py +++ b/dulwich/client.py @@ -1,1237 +1,1251 @@ # client.py -- Implementation of the server side git protocols # Copyright (C) 2008-2013 Jelmer Vernooij # Copyright (C) 2008 John Carr # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; either version 2 # or (at your option) a later version of the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. """Client side support for the Git protocol. The Dulwich client supports the following capabilities: * thin-pack * multi_ack_detailed * multi_ack * side-band-64k * ofs-delta * report-status * delete-refs Known capabilities that are not supported: * shallow * no-progress * include-tag """ __docformat__ = 'restructuredText' from contextlib import closing from io import BytesIO, BufferedReader import dulwich import select import shlex import socket import subprocess import sys try: import urllib2 import urlparse except ImportError: import urllib.request as urllib2 import urllib.parse as urlparse from dulwich.errors import ( GitProtocolError, NotGitRepository, SendPackError, UpdateRefsError, ) from dulwich.protocol import ( _RBUFSIZE, CAPABILITY_DELETE_REFS, CAPABILITY_MULTI_ACK, CAPABILITY_MULTI_ACK_DETAILED, CAPABILITY_OFS_DELTA, CAPABILITY_REPORT_STATUS, CAPABILITY_SIDE_BAND_64K, CAPABILITY_THIN_PACK, COMMAND_DONE, COMMAND_HAVE, COMMAND_WANT, SIDE_BAND_CHANNEL_DATA, SIDE_BAND_CHANNEL_PROGRESS, SIDE_BAND_CHANNEL_FATAL, PktLineParser, Protocol, ProtocolFile, TCP_GIT_PORT, ZERO_SHA, extract_capabilities, ) from dulwich.pack import ( write_pack_objects, ) from dulwich.refs import ( read_info_refs, ) def _fileno_can_read(fileno): """Check if a file descriptor is readable.""" return len(select.select([fileno], [], [], 0)[0]) > 0 COMMON_CAPABILITIES = [CAPABILITY_OFS_DELTA, CAPABILITY_SIDE_BAND_64K] FETCH_CAPABILITIES = ([CAPABILITY_THIN_PACK, CAPABILITY_MULTI_ACK, CAPABILITY_MULTI_ACK_DETAILED] + COMMON_CAPABILITIES) SEND_CAPABILITIES = [CAPABILITY_REPORT_STATUS] + COMMON_CAPABILITIES class ReportStatusParser(object): """Handle status as reported by servers with 'report-status' capability. """ def __init__(self): self._done = False self._pack_status = None self._ref_status_ok = True self._ref_statuses = [] def check(self): """Check if there were any errors and, if so, raise exceptions. :raise SendPackError: Raised when the server could not unpack :raise UpdateRefsError: Raised when refs could not be updated """ if self._pack_status not in (b'unpack ok', None): raise SendPackError(self._pack_status) if not self._ref_status_ok: ref_status = {} ok = set() for status in self._ref_statuses: if b' ' not in status: # malformed response, move on to the next one continue status, ref = status.split(b' ', 1) if status == b'ng': if b' ' in ref: ref, status = ref.split(b' ', 1) else: ok.add(ref) ref_status[ref] = status # TODO(jelmer): don't assume encoding of refs is ascii. raise UpdateRefsError(', '.join([ ref.decode('ascii') for ref in ref_status if ref not in ok]) + ' failed to update', ref_status=ref_status) def handle_packet(self, pkt): """Handle a packet. :raise GitProtocolError: Raised when packets are received after a flush packet. """ if self._done: raise GitProtocolError("received more data after status report") if pkt is None: self._done = True return if self._pack_status is None: self._pack_status = pkt.strip() else: ref_status = pkt.strip() self._ref_statuses.append(ref_status) if not ref_status.startswith(b'ok '): self._ref_status_ok = False def read_pkt_refs(proto): server_capabilities = None refs = {} # Receive refs from server for pkt in proto.read_pkt_seq(): (sha, ref) = pkt.rstrip(b'\n').split(None, 1) if sha == b'ERR': raise GitProtocolError(ref) if server_capabilities is None: (ref, server_capabilities) = extract_capabilities(ref) refs[ref] = sha if len(refs) == 0: return None, set([]) return refs, set(server_capabilities) # TODO(durin42): this doesn't correctly degrade if the server doesn't # support some capabilities. This should work properly with servers # that don't support multi_ack. class GitClient(object): """Git smart server client. """ def __init__(self, thin_packs=True, report_activity=None): """Create a new GitClient instance. :param thin_packs: Whether or not thin packs should be retrieved :param report_activity: Optional callback for reporting transport activity. """ self._report_activity = report_activity self._report_status_parser = None self._fetch_capabilities = set(FETCH_CAPABILITIES) self._send_capabilities = set(SEND_CAPABILITIES) if not thin_packs: self._fetch_capabilities.remove(CAPABILITY_THIN_PACK) def send_pack(self, path, determine_wants, generate_pack_contents, progress=None, write_pack=write_pack_objects): """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 :param write_pack: Function called with (file, iterable of objects) to write the objects returned by generate_pack_contents to the server. :raises SendPackError: if server rejects the pack data :raises UpdateRefsError: if the server supports report-status and rejects ref updates """ 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 as dictionary """ if determine_wants is None: determine_wants = target.object_store.determine_wants_all f, commit, abort = target.object_store.add_pack() try: result = self.fetch_pack( path, determine_wants, target.get_graph_walker(), f.write, progress) except: abort() raise else: commit() return result 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) """ raise NotImplementedError(self.fetch_pack) def get_refs(self, path): """Retrieve the current refs from a git smart server. :param path: Path to the repo to fetch from. """ raise NotImplementedError(self.get_refs) def _parse_status_report(self, proto): unpack = proto.read_pkt_line().strip() if unpack != b'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(b'ok '): errs = True ref_status = proto.read_pkt_line() if errs: ref_status = {} ok = set() for status in statuses: if b' ' not in status: # malformed response, move on to the next one continue status, ref = status.split(b' ', 1) if status == b'ng': if b' ' in ref: ref, status = ref.split(b' ', 1) else: ok.add(ref) ref_status[ref] = status raise UpdateRefsError(', '.join([ref for ref in ref_status if ref not in ok]) + b' failed to update', 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[:1]) 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 all_refs = set(new_refs.keys()).union(set(old_refs.keys())) for refname in all_refs: old_sha1 = old_refs.get(refname, ZERO_SHA) new_sha1 = new_refs.get(refname, ZERO_SHA) if old_sha1 != new_sha1: if sent_capabilities: proto.write_pkt_line(old_sha1 + b' ' + new_sha1 + b' ' + refname) else: proto.write_pkt_line( old_sha1 + b' ' + new_sha1 + b' ' + refname + b'\0' + b' '.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) return (have, want) def _handle_receive_pack_tail(self, proto, capabilities, progress=None): """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 b"side-band-64k" in capabilities: if progress is None: progress = lambda x: None channel_callbacks = {2: progress} if CAPABILITY_REPORT_STATUS in capabilities: channel_callbacks[1] = PktLineParser( self._report_status_parser.handle_packet).parse self._read_side_band64k_data(proto, channel_callbacks) else: if CAPABILITY_REPORT_STATUS in capabilities: for pkt in proto.read_pkt_seq(): self._report_status_parser.handle_packet(pkt) if self._report_status_parser is not None: self._report_status_parser.check() def _handle_upload_pack_head(self, proto, capabilities, graph_walker, wants, can_read): """Handle the head 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 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 """ assert isinstance(wants, list) and isinstance(wants[0], bytes) proto.write_pkt_line(COMMAND_WANT + b' ' + wants[0] + b' ' + b' '.join(capabilities) + b'\n') for want in wants[1:]: proto.write_pkt_line(COMMAND_WANT + b' ' + want + b'\n') proto.write_pkt_line(None) have = next(graph_walker) while have: proto.write_pkt_line(COMMAND_HAVE + b' ' + have + b'\n') if can_read(): pkt = proto.read_pkt_line() parts = pkt.rstrip(b'\n').split(b' ') if parts[0] == b'ACK': graph_walker.ack(parts[1]) if parts[2] in (b'continue', b'common'): pass elif parts[2] == b'ready': break else: raise AssertionError( "%s not in ('continue', 'ready', 'common)" % parts[2]) have = next(graph_walker) proto.write_pkt_line(COMMAND_DONE + b'\n') def _handle_upload_pack_tail(self, proto, capabilities, graph_walker, pack_data, progress=None, rbufsize=_RBUFSIZE): """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 :param rbufsize: Read buffer size """ pkt = proto.read_pkt_line() while pkt: parts = pkt.rstrip(b'\n').split(b' ') if parts[0] == b'ACK': graph_walker.ack(parts[1]) if len(parts) < 3 or parts[2] not in ( b'ready', b'continue', b'common'): break pkt = proto.read_pkt_line() if CAPABILITY_SIDE_BAND_64K in capabilities: if progress is None: # Just ignore progress data progress = lambda x: None self._read_side_band64k_data(proto, { SIDE_BAND_CHANNEL_DATA: pack_data, SIDE_BAND_CHANNEL_PROGRESS: progress} ) else: while True: data = proto.read(rbufsize) if data == b"": break pack_data(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. """ raise NotImplementedError() def send_pack(self, path, determine_wants, generate_pack_contents, progress=None, write_pack=write_pack_objects): """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 :param write_pack: Function called with (file, iterable of objects) to write the objects returned by generate_pack_contents to the server. :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(b'receive-pack', path) with proto: old_refs, server_capabilities = read_pkt_refs(proto) negotiated_capabilities = self._send_capabilities & server_capabilities if CAPABILITY_REPORT_STATUS in negotiated_capabilities: self._report_status_parser = ReportStatusParser() report_status_parser = self._report_status_parser try: new_refs = orig_new_refs = determine_wants(dict(old_refs)) except: proto.write_pkt_line(None) raise if not CAPABILITY_DELETE_REFS in server_capabilities: # Server does not support deletions. Fail later. new_refs = dict(orig_new_refs) for ref, sha in orig_new_refs.items(): if sha == ZERO_SHA: if CAPABILITY_REPORT_STATUS in negotiated_capabilities: report_status_parser._ref_statuses.append( b'ng ' + sha + b' remote does not support deleting refs') report_status_parser._ref_status_ok = False del new_refs[ref] if new_refs is None: proto.write_pkt_line(None) return old_refs if len(new_refs) == 0 and len(orig_new_refs): # NOOP - Original new refs filtered out by policy proto.write_pkt_line(None) if report_status_parser is not None: report_status_parser.check() return old_refs (have, want) = self._handle_receive_pack_head( proto, negotiated_capabilities, old_refs, new_refs) if not want and old_refs == new_refs: return new_refs objects = generate_pack_contents(have, want) dowrite = len(objects) > 0 dowrite = dowrite or any(old_refs.get(ref) != sha for (ref, sha) in new_refs.items() if sha != ZERO_SHA) if dowrite: write_pack(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(b'upload-pack', path) with proto: refs, server_capabilities = read_pkt_refs(proto) negotiated_capabilities = ( self._fetch_capabilities & server_capabilities) if refs is None: proto.write_pkt_line(None) return refs try: wants = determine_wants(refs) except: proto.write_pkt_line(None) raise if wants is not None: wants = [cid for cid in wants if cid != ZERO_SHA] 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 def get_refs(self, path): """Retrieve the current refs from a git smart server.""" # stock `git ls-remote` uses upload-pack proto, _ = self._connect(b'upload-pack', path) with proto: refs, _ = read_pkt_refs(proto) return refs def archive(self, path, committish, write_data, progress=None, write_error=None): proto, can_read = self._connect(b'upload-archive', path) with proto: proto.write_pkt_line(b"argument " + committish) proto.write_pkt_line(None) pkt = proto.read_pkt_line() if pkt == b"NACK\n": return elif pkt == b"ACK\n": pass elif pkt.startswith(b"ERR "): raise GitProtocolError(pkt[4:].rstrip(b"\n")) else: raise AssertionError("invalid response %r" % pkt) ret = proto.read_pkt_line() if ret is not None: raise AssertionError("expected pkt tail") self._read_side_band64k_data(proto, { SIDE_BAND_CHANNEL_DATA: write_data, SIDE_BAND_CHANNEL_PROGRESS: progress, SIDE_BAND_CHANNEL_FATAL: write_error}) class TCPGitClient(TraditionalGitClient): """A Git Client that works over TCP directly (i.e. git://).""" def __init__(self, host, port=None, *args, **kwargs): if port is None: port = TCP_GIT_PORT self._host = host self._port = port TraditionalGitClient.__init__(self, *args, **kwargs) def _connect(self, cmd, path): + if type(cmd) is not bytes: + raise TypeError(path) + if type(path) is not bytes: + raise TypeError(path) sockaddrs = socket.getaddrinfo( self._host, self._port, socket.AF_UNSPEC, socket.SOCK_STREAM) s = None err = socket.error("no address found for %s" % self._host) for (family, socktype, proto, canonname, sockaddr) in sockaddrs: s = socket.socket(family, socktype, proto) s.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) try: s.connect(sockaddr) break except socket.error as err: if s is not None: s.close() s = None if s is None: raise err # -1 means system default buffering rfile = s.makefile('rb', -1) # 0 means unbuffered wfile = s.makefile('wb', 0) def close(): rfile.close() wfile.close() s.close() proto = Protocol(rfile.read, wfile.write, close, report_activity=self._report_activity) if path.startswith(b"/~"): path = path[1:] proto.send_cmd(b'git-' + cmd, path, b'host=' + self._host) return proto, lambda: _fileno_can_read(s) class SubprocessWrapper(object): """A socket-like object that talks to a subprocess via pipes.""" def __init__(self, proc): self.proc = proc if sys.version_info[0] == 2: self.read = proc.stdout.read else: self.read = BufferedReader(proc.stdout).read self.write = proc.stdin.write def can_read(self): if subprocess.mswindows: from msvcrt import get_osfhandle from win32pipe import PeekNamedPipe handle = get_osfhandle(self.proc.stdout.fileno()) data, total_bytes_avail, msg_bytes_left = PeekNamedPipe(handle, 0) return total_bytes_avail != 0 else: return _fileno_can_read(self.proc.stdout.fileno()) def close(self): self.proc.stdin.close() self.proc.stdout.close() if self.proc.stderr: self.proc.stderr.close() self.proc.wait() def find_git_command(): """Find command to run for system Git (usually C Git). """ if sys.platform == 'win32': # support .exe, .bat and .cmd try: # to avoid overhead import win32api except ImportError: # run through cmd.exe with some overhead return ['cmd', '/c', 'git'] else: status, git = win32api.FindExecutable('git') return [git] else: return ['git'] class SubprocessGitClient(TraditionalGitClient): """Git client that talks to a server using a subprocess.""" def __init__(self, *args, **kwargs): self._connection = None self._stderr = None self._stderr = kwargs.get('stderr') if 'stderr' in kwargs: del kwargs['stderr'] TraditionalGitClient.__init__(self, *args, **kwargs) git_command = None def _connect(self, service, path): + if type(service) is not bytes: + raise TypeError(path) + if type(path) is not bytes: + raise TypeError(path) import subprocess if self.git_command is None: git_command = find_git_command() argv = git_command + [service.decode('ascii'), path] p = SubprocessWrapper( subprocess.Popen(argv, bufsize=0, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=self._stderr)) return Protocol(p.read, p.write, p.close, report_activity=self._report_activity), p.can_read class LocalGitClient(GitClient): """Git Client that just uses a local Repo.""" def __init__(self, thin_packs=True, report_activity=None): """Create a new LocalGitClient instance. :param path: Path to the local repository :param thin_packs: Whether or not thin packs should be retrieved :param report_activity: Optional callback for reporting transport activity. """ self._report_activity = report_activity # Ignore the thin_packs argument def send_pack(self, path, determine_wants, generate_pack_contents, progress=None, write_pack=write_pack_objects): """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 :param write_pack: Function called with (file, iterable of objects) to write the objects returned by generate_pack_contents to the server. :raises SendPackError: if server rejects the pack data :raises UpdateRefsError: if the server supports report-status and rejects ref updates """ from dulwich.repo import Repo with closing(Repo(path)) as target: old_refs = target.get_refs() new_refs = determine_wants(dict(old_refs)) have = [sha1 for sha1 in old_refs.values() if sha1 != ZERO_SHA] want = [] all_refs = set(new_refs.keys()).union(set(old_refs.keys())) for refname in all_refs: old_sha1 = old_refs.get(refname, ZERO_SHA) new_sha1 = new_refs.get(refname, ZERO_SHA) if new_sha1 not in have and new_sha1 != ZERO_SHA: want.append(new_sha1) if not want and old_refs == new_refs: return new_refs target.object_store.add_objects(generate_pack_contents(have, want)) for name, sha in new_refs.items(): target.refs[name] = sha 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 as dictionary """ from dulwich.repo import Repo with closing(Repo(path)) as r: return r.fetch(target, determine_wants=determine_wants, progress=progress) 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) """ from dulwich.repo import Repo with closing(Repo(path)) as r: objects_iter = r.fetch_objects(determine_wants, graph_walker, progress) # Did the process short-circuit (e.g. in a stateless RPC call)? Note # that the client still expects a 0-object pack in most cases. if objects_iter is None: return write_pack_objects(ProtocolFile(None, pack_data), objects_iter) def get_refs(self, path): """Retrieve the current refs from a git smart server.""" from dulwich.repo import Repo with closing(Repo(path)) as target: return target.get_refs() # What Git client to use for local access default_local_git_client_cls = LocalGitClient class SSHVendor(object): """A client side SSH implementation.""" def connect_ssh(self, host, command, username=None, port=None): import warnings warnings.warn( "SSHVendor.connect_ssh has been renamed to SSHVendor.run_command", DeprecationWarning) return self.run_command(host, command, username=username, port=port) def run_command(self, host, command, username=None, port=None): """Connect to an SSH server. Run a command remotely and return a file-like object for interaction with the remote command. :param host: Host name :param command: Command to run (as argv array) :param username: Optional ame of user to log in as :param port: Optional SSH port to use """ raise NotImplementedError(self.run_command) class SubprocessSSHVendor(SSHVendor): """SSH vendor that shells out to the local 'ssh' command.""" def run_command(self, host, command, username=None, port=None): if (type(command) is not list or not all([isinstance(b, bytes) for b in command])): raise TypeError(command) import subprocess #FIXME: This has no way to deal with passwords.. args = ['ssh', '-x'] if port is not None: args.extend(['-p', str(port)]) if username is not None: host = '%s@%s' % (username, host) args.append(host) proc = subprocess.Popen(args + command, stdin=subprocess.PIPE, stdout=subprocess.PIPE) return SubprocessWrapper(proc) try: import paramiko except ImportError: pass else: import threading class ParamikoWrapper(object): STDERR_READ_N = 2048 # 2k def __init__(self, client, channel, progress_stderr=None): self.client = client self.channel = channel self.progress_stderr = progress_stderr self.should_monitor = bool(progress_stderr) or True self.monitor_thread = None self.stderr = '' # Channel must block self.channel.setblocking(True) # Start if self.should_monitor: self.monitor_thread = threading.Thread( target=self.monitor_stderr) self.monitor_thread.start() def monitor_stderr(self): while self.should_monitor: # Block and read data = self.read_stderr(self.STDERR_READ_N) # Socket closed if not data: self.should_monitor = False break # Emit data if self.progress_stderr: self.progress_stderr(data) # Append to buffer self.stderr += data def stop_monitoring(self): # Stop StdErr thread if self.should_monitor: self.should_monitor = False self.monitor_thread.join() # Get left over data data = self.channel.in_stderr_buffer.empty() self.stderr += data def can_read(self): return self.channel.recv_ready() def write(self, data): return self.channel.sendall(data) def read_stderr(self, n): return self.channel.recv_stderr(n) def read(self, n=None): data = self.channel.recv(n) data_len = len(data) # Closed socket if not data: return # Read more if needed if n and data_len < n: diff_len = n - data_len return data + self.read(diff_len) return data def close(self): self.channel.close() self.stop_monitoring() class ParamikoSSHVendor(object): def __init__(self): self.ssh_kwargs = {} def run_command(self, host, command, username=None, port=None, progress_stderr=None): if (type(command) is not list or not all([isinstance(b, bytes) for b in command])): raise TypeError(command) # Paramiko needs an explicit port. None is not valid if port is None: port = 22 client = paramiko.SSHClient() policy = paramiko.client.MissingHostKeyPolicy() client.set_missing_host_key_policy(policy) client.connect(host, username=username, port=port, **self.ssh_kwargs) # Open SSH session channel = client.get_transport().open_session() # Run commands channel.exec_command(subprocess.list2cmdline(command)) return ParamikoWrapper( client, channel, progress_stderr=progress_stderr) # Can be overridden by users get_ssh_vendor = SubprocessSSHVendor class SSHGitClient(TraditionalGitClient): def __init__(self, host, port=None, username=None, *args, **kwargs): self.host = host self.port = port self.username = username TraditionalGitClient.__init__(self, *args, **kwargs) self.alternative_paths = {} def _get_cmd_path(self, cmd): cmd = self.alternative_paths.get(cmd, b'git-' + cmd) - if sys.version_info[0:2] >= (2, 7): - return [b.decode('ascii') for b in shlex.split(cmd)] + assert isinstance(cmd, bytes) + if sys.version_info[:2] <= (2, 6): + return shlex.split(cmd) else: - return shlex.split(cmd.decode('ascii')) + # TODO(jelmer): Don't decode/encode here + return [x.encode('ascii') for x in shlex.split(cmd.decode('ascii'))] def _connect(self, cmd, path): - if path.startswith("/~"): + if type(cmd) is not bytes: + raise TypeError(path) + if type(path) is not bytes: + raise TypeError(path) + if path.startswith(b"/~"): path = path[1:] + argv = self._get_cmd_path(cmd) + [path] con = get_ssh_vendor().run_command( - self.host, self._get_cmd_path(cmd) + [path], - port=self.port, username=self.username) + self.host, argv, port=self.port, username=self.username) return (Protocol(con.read, con.write, con.close, report_activity=self._report_activity), con.can_read) def default_user_agent_string(): return "dulwich/%s" % ".".join([str(x) for x in dulwich.__version__]) def default_urllib2_opener(config): if config is not None: proxy_server = config.get("http", "proxy") else: proxy_server = None handlers = [] if proxy_server is not None: handlers.append(urllib2.ProxyHandler({"http": proxy_server})) opener = urllib2.build_opener(*handlers) if config is not None: user_agent = config.get("http", "useragent") else: user_agent = None if user_agent is None: user_agent = default_user_agent_string() opener.addheaders = [('User-agent', user_agent)] return opener class HttpGitClient(GitClient): def __init__(self, base_url, dumb=None, opener=None, config=None, *args, **kwargs): self.base_url = base_url.rstrip("/") + "/" self.dumb = dumb if opener is None: self.opener = default_urllib2_opener(config) else: self.opener = opener GitClient.__init__(self, *args, **kwargs) def __repr__(self): return "%s(%r, dumb=%r)" % (type(self).__name__, self.base_url, self.dumb) def _get_url(self, path): return urlparse.urljoin(self.base_url, path).rstrip("/") + "/" def _http_request(self, url, headers={}, data=None): req = urllib2.Request(url, headers=headers, data=data) try: resp = self.opener.open(req) except urllib2.HTTPError as e: if e.code == 404: raise NotGitRepository() if e.code != 200: raise GitProtocolError("unexpected http response %d" % e.code) return resp def _discover_references(self, service, url): assert url[-1] == "/" url = urlparse.urljoin(url, "info/refs") headers = {} if self.dumb is not False: url += "?service=%s" % service headers["Content-Type"] = "application/x-%s-request" % service resp = self._http_request(url, headers) try: self.dumb = (not resp.info().gettype().startswith("application/x-git-")) if not self.dumb: proto = Protocol(resp.read, None) # The first line should mention the service pkts = list(proto.read_pkt_seq()) if pkts != [('# service=%s\n' % service)]: raise GitProtocolError( "unexpected first line %r from smart server" % pkts) return read_pkt_refs(proto) else: return read_info_refs(resp), set() finally: resp.close() def _smart_request(self, service, url, data): assert url[-1] == "/" url = urlparse.urljoin(url, service) headers = {"Content-Type": "application/x-%s-request" % service} resp = self._http_request(url, headers, data) if resp.info().gettype() != ("application/x-%s-result" % service): raise GitProtocolError("Invalid content-type from server: %s" % resp.info().gettype()) return resp def send_pack(self, path, determine_wants, generate_pack_contents, progress=None, write_pack=write_pack_objects): """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 :param write_pack: Function called with (file, iterable of objects) to write the objects returned by generate_pack_contents to the server. :raises SendPackError: if server rejects the pack data :raises UpdateRefsError: if the server supports report-status and rejects ref updates """ url = self._get_url(path) old_refs, server_capabilities = self._discover_references( b"git-receive-pack", url) negotiated_capabilities = self._send_capabilities & server_capabilities if CAPABILITY_REPORT_STATUS in negotiated_capabilities: self._report_status_parser = ReportStatusParser() new_refs = determine_wants(dict(old_refs)) if new_refs is None: return old_refs if self.dumb: raise NotImplementedError(self.fetch_pack) req_data = BytesIO() 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 and old_refs == new_refs: return new_refs objects = generate_pack_contents(have, want) if len(objects) > 0: write_pack(req_proto.write_file(), objects) resp = self._smart_request(b"git-receive-pack", url, data=req_data.getvalue()) try: resp_proto = Protocol(resp.read, None) self._handle_receive_pack_tail(resp_proto, negotiated_capabilities, progress) return new_refs finally: resp.close() 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) :return: Dictionary with the refs of the remote repository """ url = self._get_url(path) refs, server_capabilities = self._discover_references( b"git-upload-pack", url) negotiated_capabilities = self._fetch_capabilities & server_capabilities wants = determine_wants(refs) if wants is not None: wants = [cid for cid in wants if cid != ZERO_SHA] if not wants: return refs if self.dumb: raise NotImplementedError(self.send_pack) req_data = BytesIO() 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( b"git-upload-pack", url, data=req_data.getvalue()) try: resp_proto = Protocol(resp.read, None) self._handle_upload_pack_tail(resp_proto, negotiated_capabilities, graph_walker, pack_data, progress) return refs finally: resp.close() def get_refs(self, path): """Retrieve the current refs from a git smart server.""" url = self._get_url(path) refs, _ = self._discover_references( b"git-upload-pack", url) return refs def get_transport_and_path_from_url(url, config=None, **kwargs): """Obtain a git client from a URL. :param url: URL to open :param config: Optional config object :param thin_packs: Whether or not thin packs should be retrieved :param report_activity: Optional callback for reporting transport activity. :return: Tuple with client instance and relative path. """ parsed = urlparse.urlparse(url) if parsed.scheme == 'git': return (TCPGitClient(parsed.hostname, port=parsed.port, **kwargs), parsed.path) elif parsed.scheme == 'git+ssh': path = parsed.path if path.startswith('/'): path = parsed.path[1:] return SSHGitClient(parsed.hostname, port=parsed.port, username=parsed.username, **kwargs), path elif parsed.scheme in ('http', 'https'): return HttpGitClient(urlparse.urlunparse(parsed), config=config, **kwargs), parsed.path elif parsed.scheme == 'file': return default_local_git_client_cls(**kwargs), parsed.path raise ValueError("unknown scheme '%s'" % parsed.scheme) def get_transport_and_path(location, **kwargs): """Obtain a git client from a URL. :param location: URL or path :param config: Optional config object :param thin_packs: Whether or not thin packs should be retrieved :param report_activity: Optional callback for reporting transport activity. :return: Tuple with client instance and relative path. """ # First, try to parse it as a URL try: return get_transport_and_path_from_url(location, **kwargs) except ValueError: pass if (sys.platform == 'win32' and location[0].isalpha() and location[1:3] == ':\\'): # Windows local path return default_local_git_client_cls(**kwargs), location if ':' in location and not '@' in location: # SSH with no user@, zero or one leading slash. (hostname, path) = location.split(':') return SSHGitClient(hostname, **kwargs), path elif '@' in location and ':' in location: # SSH with user@host:foo. user_host, path = location.split(':') user, host = user_host.rsplit('@') return SSHGitClient(host, username=user, **kwargs), path # Otherwise, assume it's a local path. return default_local_git_client_cls(**kwargs), location diff --git a/dulwich/porcelain.py b/dulwich/porcelain.py index 8b6a9a3c..2f55c6eb 100644 --- a/dulwich/porcelain.py +++ b/dulwich/porcelain.py @@ -1,789 +1,791 @@ # porcelain.py -- Porcelain-like layer on top of Dulwich # Copyright (C) 2013 Jelmer Vernooij # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; either version 2 # or (at your option) a later version of the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. """Simple wrapper that provides porcelain-like functions on top of Dulwich. Currently implemented: * archive * add * branch{_create,_delete,_list} * clone * commit * commit-tree * daemon * diff-tree * fetch * init * ls-remote * pull * push * rm * receive-pack * reset * rev-list * tag{_create,_delete,_list} * upload-pack * update-server-info * status * symbolic-ref These functions are meant to behave similarly to the git subcommands. Differences in behaviour are considered bugs. """ __docformat__ = 'restructuredText' from collections import namedtuple from contextlib import ( closing, contextmanager, ) import os import sys import time from dulwich.client import ( get_transport_and_path, SubprocessGitClient, ) from dulwich.errors import ( SendPackError, UpdateRefsError, ) from dulwich.index import get_unstaged_changes from dulwich.objects import ( Tag, parse_timezone, ) from dulwich.objectspec import ( parse_object, parse_reftuples, ) from dulwich.patch import write_tree_diff from dulwich.protocol import Protocol from dulwich.repo import (BaseRepo, Repo) from dulwich.server import ( FileSystemBackend, TCPGitServer, ReceivePackHandler, UploadPackHandler, update_server_info as server_update_server_info, ) # Module level tuple definition for status output GitStatus = namedtuple('GitStatus', 'staged unstaged untracked') def open_repo(path_or_repo): """Open an argument that can be a repository or a path for a repository.""" if isinstance(path_or_repo, BaseRepo): return path_or_repo return Repo(path_or_repo) @contextmanager def _noop_context_manager(obj): """Context manager that has the same api as closing but does nothing.""" yield obj def open_repo_closing(path_or_repo): """Open an argument that can be a repository or a path for a repository. returns a context manager that will close the repo on exit if the argument is a path, else does nothing if the argument is a repo. """ if isinstance(path_or_repo, BaseRepo): return _noop_context_manager(path_or_repo) return closing(Repo(path_or_repo)) def archive(path, committish=None, outstream=sys.stdout, errstream=sys.stderr): """Create an archive. :param path: Path of repository for which to generate an archive. :param committish: Commit SHA1 or ref to use :param outstream: Output stream (defaults to stdout) :param errstream: Error stream (defaults to stderr) """ client = SubprocessGitClient() if committish is None: committish = "HEAD" + if not isinstance(path, bytes): + path = path.encode(sys.getfilesystemencoding()) # TODO(jelmer): This invokes C git; this introduces a dependency. # Instead, dulwich should have its own archiver implementation. client.archive(path, committish, outstream.write, errstream.write, errstream.write) def update_server_info(repo="."): """Update server info files for a repository. :param repo: path to the repository """ with open_repo_closing(repo) as r: server_update_server_info(r) def symbolic_ref(repo, ref_name, force=False): """Set git symbolic ref into HEAD. :param repo: path to the repository :param ref_name: short name of the new ref :param force: force settings without checking if it exists in refs/heads """ with open_repo_closing(repo) as repo_obj: ref_path = b'refs/heads/' + ref_name if not force and ref_path not in repo_obj.refs.keys(): raise ValueError('fatal: ref `%s` is not a ref' % ref_name) repo_obj.refs.set_symbolic_ref(b'HEAD', ref_path) def commit(repo=".", message=None, author=None, committer=None): """Create a new commit. :param repo: Path to repository :param message: Optional commit message :param author: Optional author name and email :param committer: Optional committer name and email :return: SHA1 of the new commit """ # FIXME: Support --all argument # FIXME: Support --signoff argument with open_repo_closing(repo) as r: return r.do_commit(message=message, author=author, committer=committer) def commit_tree(repo, tree, message=None, author=None, committer=None): """Create a new commit object. :param repo: Path to repository :param tree: An existing tree object :param author: Optional author name and email :param committer: Optional committer name and email """ with open_repo_closing(repo) as r: return r.do_commit(message=message, tree=tree, committer=committer, author=author) def init(path=".", bare=False): """Create a new git repository. :param path: Path to repository. :param bare: Whether to create a bare repository. :return: A Repo instance """ if not os.path.exists(path): os.mkdir(path) if bare: return Repo.init_bare(path) else: return Repo.init(path) def clone(source, target=None, bare=False, checkout=None, errstream=sys.stdout, outstream=None): """Clone a local or remote git repository. :param source: Path or URL for source repository :param target: Path to target repository (optional) :param bare: Whether or not to create a bare repository :param errstream: Optional stream to write progress to :param outstream: Optional stream to write progress to (deprecated) :return: The new repository """ if outstream is not None: import warnings warnings.warn("outstream= has been deprecated in favour of errstream=.", DeprecationWarning, stacklevel=3) errstream = outstream if checkout is None: checkout = (not bare) if checkout and bare: raise ValueError("checkout and bare are incompatible") client, host_path = get_transport_and_path(source) if target is None: target = host_path.split("/")[-1] if not os.path.exists(target): os.mkdir(target) if bare: r = Repo.init_bare(target) else: r = Repo.init(target) try: remote_refs = client.fetch(host_path, r, determine_wants=r.object_store.determine_wants_all, progress=errstream.write) r[b"HEAD"] = remote_refs[b"HEAD"] if checkout: errstream.write(b'Checking out HEAD\n') r.reset_index() except: r.close() raise return r def add(repo=".", paths=None): """Add files to the staging area. :param repo: Repository for the files :param paths: Paths to add. No value passed stages all modified files. """ # FIXME: Support patterns, directories. with open_repo_closing(repo) as r: if not paths: # If nothing is specified, add all non-ignored files. paths = [] for dirpath, dirnames, filenames in os.walk(r.path): # Skip .git and below. if '.git' in dirnames: dirnames.remove('.git') for filename in filenames: paths.append(os.path.join(dirpath[len(r.path)+1:], filename)) r.stage(paths) def rm(repo=".", paths=None): """Remove files from the staging area. :param repo: Repository for the files :param paths: Paths to remove """ with open_repo_closing(repo) as r: index = r.open_index() for p in paths: del index[p.encode(sys.getfilesystemencoding())] index.write() def commit_decode(commit, contents): if commit.encoding is not None: return contents.decode(commit.encoding, "replace") return contents.decode("utf-8", "replace") def print_commit(commit, outstream=sys.stdout): """Write a human-readable commit log entry. :param commit: A `Commit` object :param outstream: A stream file to write to """ outstream.write(b"-" * 50 + b"\n") outstream.write(b"commit: " + commit.id + b"\n") if len(commit.parents) > 1: outstream.write(b"merge: " + b"...".join(commit.parents[1:]) + b"\n") outstream.write(b"author: " + commit.author + b"\n") outstream.write(b"committer: " + commit.committer + b"\n") outstream.write(b"\n") outstream.write(commit.message + b"\n") outstream.write(b"\n") def print_tag(tag, outstream=sys.stdout): """Write a human-readable tag. :param tag: A `Tag` object :param outstream: A stream to write to """ outstream.write(b"Tagger: " + tag.tagger + b"\n") outstream.write(b"Date: " + tag.tag_time + b"\n") outstream.write(b"\n") outstream.write(tag.message + b"\n") outstream.write(b"\n") def show_blob(repo, blob, outstream=sys.stdout): """Write a blob to a stream. :param repo: A `Repo` object :param blob: A `Blob` object :param outstream: A stream file to write to """ outstream.write(blob.data) def show_commit(repo, commit, outstream=sys.stdout): """Show a commit to a stream. :param repo: A `Repo` object :param commit: A `Commit` object :param outstream: Stream to write to """ print_commit(commit, outstream) parent_commit = repo[commit.parents[0]] write_tree_diff(outstream, repo.object_store, parent_commit.tree, commit.tree) def show_tree(repo, tree, outstream=sys.stdout): """Print a tree to a stream. :param repo: A `Repo` object :param tree: A `Tree` object :param outstream: Stream to write to """ for n in tree: outstream.write("%s\n" % n) def show_tag(repo, tag, outstream=sys.stdout): """Print a tag to a stream. :param repo: A `Repo` object :param tag: A `Tag` object :param outstream: Stream to write to """ print_tag(tag, outstream) show_object(repo, repo[tag.object[1]], outstream) def show_object(repo, obj, outstream): return { b"tree": show_tree, b"blob": show_blob, b"commit": show_commit, b"tag": show_tag, }[obj.type_name](repo, obj, outstream) def log(repo=".", outstream=sys.stdout, max_entries=None): """Write commit logs. :param repo: Path to repository :param outstream: Stream to write log output to :param max_entries: Optional maximum number of entries to display """ with open_repo_closing(repo) as r: walker = r.get_walker(max_entries=max_entries) for entry in walker: print_commit(entry.commit, outstream) def show(repo=".", objects=None, outstream=sys.stdout): """Print the changes in a commit. :param repo: Path to repository :param objects: Objects to show (defaults to [HEAD]) :param outstream: Stream to write to """ if objects is None: objects = ["HEAD"] if not isinstance(objects, list): objects = [objects] with open_repo_closing(repo) as r: for objectish in objects: show_object(r, parse_object(r, objectish), outstream) def diff_tree(repo, old_tree, new_tree, outstream=sys.stdout): """Compares the content and mode of blobs found via two tree objects. :param repo: Path to repository :param old_tree: Id of old tree :param new_tree: Id of new tree :param outstream: Stream to write to """ with open_repo_closing(repo) as r: write_tree_diff(outstream, r.object_store, old_tree, new_tree) def rev_list(repo, commits, outstream=sys.stdout): """Lists commit objects in reverse chronological order. :param repo: Path to repository :param commits: Commits over which to iterate :param outstream: Stream to write to """ with open_repo_closing(repo) as r: for entry in r.get_walker(include=[r[c].id for c in commits]): outstream.write(entry.commit.id + b"\n") def tag(*args, **kwargs): import warnings warnings.warn("tag has been deprecated in favour of tag_create.", DeprecationWarning) return tag_create(*args, **kwargs) def tag_create(repo, tag, author=None, message=None, annotated=False, objectish="HEAD", tag_time=None, tag_timezone=None): """Creates a tag in git via dulwich calls: :param repo: Path to repository :param tag: tag string :param author: tag author (optional, if annotated is set) :param message: tag message (optional) :param annotated: whether to create an annotated tag :param objectish: object the tag should point at, defaults to HEAD :param tag_time: Optional time for annotated tag :param tag_timezone: Optional timezone for annotated tag """ with open_repo_closing(repo) as r: object = parse_object(r, objectish) if annotated: # Create the tag object tag_obj = Tag() if author is None: # TODO(jelmer): Don't use repo private method. author = r._get_user_identity() tag_obj.tagger = author tag_obj.message = message tag_obj.name = tag tag_obj.object = (type(object), object.id) tag_obj.tag_time = tag_time if tag_time is None: tag_time = int(time.time()) if tag_timezone is None: # TODO(jelmer) Use current user timezone rather than UTC tag_timezone = 0 elif isinstance(tag_timezone, str): tag_timezone = parse_timezone(tag_timezone) tag_obj.tag_timezone = tag_timezone r.object_store.add_object(tag_obj) tag_id = tag_obj.id else: tag_id = object.id r.refs[b'refs/tags/' + tag] = tag_id def list_tags(*args, **kwargs): import warnings warnings.warn("list_tags has been deprecated in favour of tag_list.", DeprecationWarning) return tag_list(*args, **kwargs) def tag_list(repo, outstream=sys.stdout): """List all tags. :param repo: Path to repository :param outstream: Stream to write tags to """ with open_repo_closing(repo) as r: tags = list(r.refs.as_dict(b"refs/tags")) tags.sort() return tags def tag_delete(repo, name): """Remove a tag. :param repo: Path to repository :param name: Name of tag to remove """ with open_repo_closing(repo) as r: if isinstance(name, bytes): names = [name] elif isinstance(name, list): names = name else: raise TypeError("Unexpected tag name type %r" % name) for name in names: del r.refs[b"refs/tags/" + name] def reset(repo, mode, committish="HEAD"): """Reset current HEAD to the specified state. :param repo: Path to repository :param mode: Mode ("hard", "soft", "mixed") """ if mode != "hard": raise ValueError("hard is the only mode currently supported") with open_repo_closing(repo) as r: tree = r[committish].tree r.reset_index() def push(repo, remote_location, refspecs=None, outstream=sys.stdout, errstream=sys.stderr): """Remote push with dulwich via dulwich.client :param repo: Path to repository :param remote_location: Location of the remote :param refspecs: relative path to the refs to push to remote :param outstream: A stream file to write output :param errstream: A stream file to write errors """ # Open the repo with open_repo_closing(repo) as r: # Get the client and path client, path = get_transport_and_path(remote_location) selected_refs = [] def update_refs(refs): selected_refs.extend(parse_reftuples(r.refs, refs, refspecs)) # TODO: Handle selected_refs == {None: None} for (lh, rh, force) in selected_refs: if lh is None: del refs[rh] else: refs[rh] = r.refs[lh] return refs err_encoding = getattr(errstream, 'encoding', 'utf-8') remote_location_bytes = remote_location.encode(err_encoding) try: client.send_pack(path, update_refs, r.object_store.generate_pack_contents, progress=errstream.write) errstream.write(b"Push to " + remote_location_bytes + b" successful.\n") except (UpdateRefsError, SendPackError) as e: errstream.write(b"Push to " + remote_location_bytes + b" failed -> " + e.message.encode(err_encoding) + b"\n") def pull(repo, remote_location, refspecs=None, outstream=sys.stdout, errstream=sys.stderr): """Pull from remote via dulwich.client :param repo: Path to repository :param remote_location: Location of the remote :param refspec: refspecs to fetch :param outstream: A stream file to write to output :param errstream: A stream file to write to errors """ # Open the repo with open_repo_closing(repo) as r: selected_refs = [] def determine_wants(remote_refs): selected_refs.extend(parse_reftuples(remote_refs, r.refs, refspecs)) return [remote_refs[lh] for (lh, rh, force) in selected_refs] client, path = get_transport_and_path(remote_location) remote_refs = client.fetch(path, r, progress=errstream.write, determine_wants=determine_wants) for (lh, rh, force) in selected_refs: r.refs[rh] = remote_refs[lh] if selected_refs: r[b'HEAD'] = remote_refs[selected_refs[0][1]] # Perform 'git checkout .' - syncs staged changes tree = r[b"HEAD"].tree r.reset_index() def status(repo="."): """Returns staged, unstaged, and untracked changes relative to the HEAD. :param repo: Path to repository or repository object :return: GitStatus tuple, staged - list of staged paths (diff index/HEAD) unstaged - list of unstaged paths (diff index/working-tree) untracked - list of untracked, un-ignored & non-.git paths """ with open_repo_closing(repo) as r: # 1. Get status of staged tracked_changes = get_tree_changes(r) # 2. Get status of unstaged unstaged_changes = list(get_unstaged_changes(r.open_index(), r.path)) # TODO - Status of untracked - add untracked changes, need gitignore. untracked_changes = [] return GitStatus(tracked_changes, unstaged_changes, untracked_changes) def get_tree_changes(repo): """Return add/delete/modify changes to tree by comparing index to HEAD. :param repo: repo path or object :return: dict with lists for each type of change """ with open_repo_closing(repo) as r: index = r.open_index() # Compares the Index to the HEAD & determines changes # Iterate through the changes and report add/delete/modify # TODO: call out to dulwich.diff_tree somehow. tracked_changes = { 'add': [], 'delete': [], 'modify': [], } for change in index.changes_from_tree(r.object_store, r[b'HEAD'].tree): if not change[0][0]: tracked_changes['add'].append(change[0][1]) elif not change[0][1]: tracked_changes['delete'].append(change[0][0]) elif change[0][0] == change[0][1]: tracked_changes['modify'].append(change[0][0]) else: raise AssertionError('git mv ops not yet supported') return tracked_changes def daemon(path=".", address=None, port=None): """Run a daemon serving Git requests over TCP/IP. :param path: Path to the directory to serve. :param address: Optional address to listen on (defaults to ::) :param port: Optional port to listen on (defaults to TCP_GIT_PORT) """ # TODO(jelmer): Support git-daemon-export-ok and --export-all. backend = FileSystemBackend(path) server = TCPGitServer(backend, address, port) server.serve_forever() def web_daemon(path=".", address=None, port=None): """Run a daemon serving Git requests over HTTP. :param path: Path to the directory to serve :param address: Optional address to listen on (defaults to ::) :param port: Optional port to listen on (defaults to 80) """ from dulwich.web import ( make_wsgi_chain, make_server, WSGIRequestHandlerLogger, WSGIServerLogger) backend = FileSystemBackend(path) app = make_wsgi_chain(backend) server = make_server(address, port, app, handler_class=WSGIRequestHandlerLogger, server_class=WSGIServerLogger) server.serve_forever() def upload_pack(path=".", inf=None, outf=None): """Upload a pack file after negotiating its contents using smart protocol. :param path: Path to the repository :param inf: Input stream to communicate with client :param outf: Output stream to communicate with client """ if outf is None: outf = getattr(sys.stdout, 'buffer', sys.stdout) if inf is None: inf = getattr(sys.stdin, 'buffer', sys.stdin) backend = FileSystemBackend(path) def send_fn(data): outf.write(data) outf.flush() proto = Protocol(inf.read, send_fn) handler = UploadPackHandler(backend, [path], proto) # FIXME: Catch exceptions and write a single-line summary to outf. handler.handle() return 0 def receive_pack(path=".", inf=None, outf=None): """Receive a pack file after negotiating its contents using smart protocol. :param path: Path to the repository :param inf: Input stream to communicate with client :param outf: Output stream to communicate with client """ if outf is None: outf = getattr(sys.stdout, 'buffer', sys.stdout) if inf is None: inf = getattr(sys.stdin, 'buffer', sys.stdin) backend = FileSystemBackend(path) def send_fn(data): outf.write(data) outf.flush() proto = Protocol(inf.read, send_fn) handler = ReceivePackHandler(backend, [path], proto) # FIXME: Catch exceptions and write a single-line summary to outf. handler.handle() return 0 def branch_delete(repo, name): """Delete a branch. :param repo: Path to the repository :param name: Name of the branch """ with open_repo_closing(repo) as r: if isinstance(name, bytes): names = [name] elif isinstance(name, list): names = name else: raise TypeError("Unexpected branch name type %r" % name) for name in names: del r.refs[b"refs/heads/" + name] def branch_create(repo, name, objectish=None, force=False): """Create a branch. :param repo: Path to the repository :param name: Name of the new branch :param objectish: Target object to point new branch at (defaults to HEAD) :param force: Force creation of branch, even if it already exists """ with open_repo_closing(repo) as r: if isinstance(name, bytes): names = [name] elif isinstance(name, list): names = name else: raise TypeError("Unexpected branch name type %r" % name) if objectish is None: objectish = "HEAD" object = parse_object(r, objectish) refname = b"refs/heads/" + name if refname in r.refs and not force: raise KeyError("Branch with name %s already exists." % name) r.refs[refname] = object.id def branch_list(repo): """List all branches. :param repo: Path to the repository """ with open_repo_closing(repo) as r: return r.refs.keys(base=b"refs/heads/") def fetch(repo, remote_location, outstream=sys.stdout, errstream=sys.stderr): """Fetch objects from a remote server. :param repo: Path to the repository :param remote_location: String identifying a remote server :param outstream: Output stream (defaults to stdout) :param errstream: Error stream (defaults to stderr) :return: Dictionary with refs on the remote """ with open_repo_closing(repo) as r: client, path = get_transport_and_path(remote_location) remote_refs = client.fetch(path, r, progress=errstream.write) return remote_refs def ls_remote(remote): client, host_path = get_transport_and_path(remote) return client.get_refs(host_path) diff --git a/dulwich/tests/compat/test_client.py b/dulwich/tests/compat/test_client.py index 71ea1ac3..a09c5d57 100644 --- a/dulwich/tests/compat/test_client.py +++ b/dulwich/tests/compat/test_client.py @@ -1,532 +1,533 @@ # test_client.py -- Compatibilty tests for git client. # Copyright (C) 2010 Google, Inc. # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; version 2 # of the License or (at your option) any later version of # the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. """Compatibilty tests between the Dulwich client and the cgit server.""" from contextlib import closing import copy from io import BytesIO import os import select import signal import subprocess import sys import tarfile import tempfile import threading try: from urlparse import unquote except ImportError: from urllib.parse import unquote try: import BaseHTTPServer import SimpleHTTPServer except ImportError: import http.server BaseHTTPServer = http.server SimpleHTTPServer = http.server if sys.platform == 'win32': import ctypes from dulwich import ( client, errors, file, index, protocol, objects, repo, ) from dulwich.tests import ( get_safe_env, SkipTest, expectedFailure, ) from dulwich.tests.utils import ( skipIfPY3, ) from dulwich.tests.compat.utils import ( CompatTestCase, check_for_daemon, import_repo_to_dir, run_git_or_fail, _DEFAULT_GIT, rmtree_ro, ) class DulwichClientTestBase(object): """Tests for client/server compatibility.""" def setUp(self): self.gitroot = os.path.dirname(import_repo_to_dir('server_new.export').rstrip(os.sep)) self.dest = os.path.join(self.gitroot, 'dest') file.ensure_dir_exists(self.dest) run_git_or_fail(['init', '--quiet', '--bare'], cwd=self.dest) def tearDown(self): rmtree_ro(self.gitroot) def assertDestEqualsSrc(self): repo_dir = os.path.join(self.gitroot, 'server_new.export') dest_repo_dir = os.path.join(self.gitroot, 'dest') with closing(repo.Repo(repo_dir)) as src: with closing(repo.Repo(dest_repo_dir)) as dest: self.assertReposEqual(src, dest) def _client(self): raise NotImplementedError() def _build_path(self): raise NotImplementedError() def _do_send_pack(self): c = self._client() srcpath = os.path.join(self.gitroot, 'server_new.export') with closing(repo.Repo(srcpath)) as src: sendrefs = dict(src.get_refs()) del sendrefs[b'HEAD'] - c.send_pack(self._build_path('/dest'), lambda _: sendrefs, + c.send_pack(self._build_path(b'/dest'), lambda _: sendrefs, src.object_store.generate_pack_contents) def test_send_pack(self): self._do_send_pack() self.assertDestEqualsSrc() def test_send_pack_nothing_to_send(self): self._do_send_pack() self.assertDestEqualsSrc() # nothing to send, but shouldn't raise either. self._do_send_pack() def test_send_without_report_status(self): c = self._client() c._send_capabilities.remove(b'report-status') srcpath = os.path.join(self.gitroot, 'server_new.export') with closing(repo.Repo(srcpath)) as src: sendrefs = dict(src.get_refs()) del sendrefs[b'HEAD'] - c.send_pack(self._build_path('/dest'), lambda _: sendrefs, + c.send_pack(self._build_path(b'/dest'), lambda _: sendrefs, src.object_store.generate_pack_contents) self.assertDestEqualsSrc() def make_dummy_commit(self, dest): b = objects.Blob.from_string(b'hi') dest.object_store.add_object(b) t = index.commit_tree(dest.object_store, [(b'hi', b.id, 0o100644)]) c = objects.Commit() c.author = c.committer = b'Foo Bar ' c.author_time = c.commit_time = 0 c.author_timezone = c.commit_timezone = 0 c.message = b'hi' c.tree = t dest.object_store.add_object(c) return c.id def disable_ff_and_make_dummy_commit(self): # disable non-fast-forward pushes to the server dest = repo.Repo(os.path.join(self.gitroot, 'dest')) run_git_or_fail(['config', 'receive.denyNonFastForwards', 'true'], cwd=dest.path) commit_id = self.make_dummy_commit(dest) return dest, commit_id def compute_send(self, src): sendrefs = dict(src.get_refs()) del sendrefs[b'HEAD'] return sendrefs, src.object_store.generate_pack_contents def test_send_pack_one_error(self): dest, dummy_commit = self.disable_ff_and_make_dummy_commit() dest.refs[b'refs/heads/master'] = dummy_commit repo_dir = os.path.join(self.gitroot, 'server_new.export') with closing(repo.Repo(repo_dir)) as src: sendrefs, gen_pack = self.compute_send(src) c = self._client() try: - c.send_pack(self._build_path('/dest'), lambda _: sendrefs, gen_pack) + c.send_pack(self._build_path(b'/dest'), lambda _: sendrefs, gen_pack) except errors.UpdateRefsError as e: self.assertEqual('refs/heads/master failed to update', e.args[0]) self.assertEqual({b'refs/heads/branch': b'ok', b'refs/heads/master': b'non-fast-forward'}, e.ref_status) def test_send_pack_multiple_errors(self): dest, dummy = self.disable_ff_and_make_dummy_commit() # set up for two non-ff errors branch, master = b'refs/heads/branch', b'refs/heads/master' dest.refs[branch] = dest.refs[master] = dummy repo_dir = os.path.join(self.gitroot, 'server_new.export') with closing(repo.Repo(repo_dir)) as src: sendrefs, gen_pack = self.compute_send(src) c = self._client() try: - c.send_pack(self._build_path('/dest'), lambda _: sendrefs, gen_pack) + c.send_pack(self._build_path(b'/dest'), lambda _: sendrefs, gen_pack) except errors.UpdateRefsError as e: self.assertIn(str(e), ['{0}, {1} failed to update'.format( branch.decode('ascii'), master.decode('ascii')), '{1}, {0} failed to update'.format( branch.decode('ascii'), master.decode('ascii'))]) self.assertEqual({branch: b'non-fast-forward', master: b'non-fast-forward'}, e.ref_status) def test_archive(self): c = self._client() f = BytesIO() - c.archive(self._build_path('/server_new.export'), b'HEAD', f.write) + c.archive(self._build_path(b'/server_new.export'), b'HEAD', f.write) f.seek(0) tf = tarfile.open(fileobj=f) self.assertEqual(['baz', 'foo'], tf.getnames()) def test_fetch_pack(self): c = self._client() with closing(repo.Repo(os.path.join(self.gitroot, 'dest'))) as dest: - refs = c.fetch(self._build_path('/server_new.export'), dest) + refs = c.fetch(self._build_path(b'/server_new.export'), dest) for r in refs.items(): dest.refs.set_if_equals(r[0], None, r[1]) self.assertDestEqualsSrc() def test_incremental_fetch_pack(self): self.test_fetch_pack() dest, dummy = self.disable_ff_and_make_dummy_commit() dest.refs[b'refs/heads/master'] = dummy c = self._client() repo_dir = os.path.join(self.gitroot, 'server_new.export') with closing(repo.Repo(repo_dir)) as dest: - refs = c.fetch(self._build_path('/dest'), dest) + refs = c.fetch(self._build_path(b'/dest'), dest) for r in refs.items(): dest.refs.set_if_equals(r[0], None, r[1]) self.assertDestEqualsSrc() def test_fetch_pack_no_side_band_64k(self): c = self._client() c._fetch_capabilities.remove(b'side-band-64k') with closing(repo.Repo(os.path.join(self.gitroot, 'dest'))) as dest: - refs = c.fetch(self._build_path('/server_new.export'), dest) + refs = c.fetch(self._build_path(b'/server_new.export'), dest) for r in refs.items(): dest.refs.set_if_equals(r[0], None, r[1]) self.assertDestEqualsSrc() def test_fetch_pack_zero_sha(self): # zero sha1s are already present on the client, and should # be ignored c = self._client() with closing(repo.Repo(os.path.join(self.gitroot, 'dest'))) as dest: - refs = c.fetch(self._build_path('/server_new.export'), dest, + refs = c.fetch(self._build_path(b'/server_new.export'), dest, lambda refs: [protocol.ZERO_SHA]) for r in refs.items(): dest.refs.set_if_equals(r[0], None, r[1]) def test_send_remove_branch(self): with closing(repo.Repo(os.path.join(self.gitroot, 'dest'))) as dest: dummy_commit = self.make_dummy_commit(dest) dest.refs[b'refs/heads/master'] = dummy_commit dest.refs[b'refs/heads/abranch'] = dummy_commit sendrefs = dict(dest.refs) sendrefs[b'refs/heads/abranch'] = b"00" * 20 del sendrefs[b'HEAD'] gen_pack = lambda have, want: [] c = self._client() self.assertEqual(dest.refs[b"refs/heads/abranch"], dummy_commit) - c.send_pack(self._build_path('/dest'), lambda _: sendrefs, gen_pack) + c.send_pack(self._build_path(b'/dest'), lambda _: sendrefs, gen_pack) self.assertFalse(b"refs/heads/abranch" in dest.refs) def test_get_refs(self): c = self._client() - refs = c.get_refs(self._build_path('/server_new.export')) + refs = c.get_refs(self._build_path(b'/server_new.export')) repo_dir = os.path.join(self.gitroot, 'server_new.export') with closing(repo.Repo(repo_dir)) as dest: self.assertDictEqual(dest.refs.as_dict(), refs) class DulwichTCPClientTest(CompatTestCase, DulwichClientTestBase): def setUp(self): CompatTestCase.setUp(self) DulwichClientTestBase.setUp(self) if check_for_daemon(limit=1): raise SkipTest('git-daemon was already running on port %s' % protocol.TCP_GIT_PORT) env = get_safe_env() fd, self.pidfile = tempfile.mkstemp(prefix='dulwich-test-git-client', suffix=".pid") os.fdopen(fd).close() args = [_DEFAULT_GIT, 'daemon', '--verbose', '--export-all', '--pid-file=%s' % self.pidfile, '--base-path=%s' % self.gitroot, '--enable=receive-pack', '--enable=upload-archive', '--listen=localhost', '--reuseaddr', self.gitroot] self.process = subprocess.Popen( args, env=env, cwd=self.gitroot, stdout=subprocess.PIPE, stderr=subprocess.PIPE) if not check_for_daemon(): raise SkipTest('git-daemon failed to start') def tearDown(self): with open(self.pidfile) as f: pid = int(f.read().strip()) if sys.platform == 'win32': PROCESS_TERMINATE = 1 handle = ctypes.windll.kernel32.OpenProcess( PROCESS_TERMINATE, False, pid) ctypes.windll.kernel32.TerminateProcess(handle, -1) ctypes.windll.kernel32.CloseHandle(handle) else: try: os.kill(pid, signal.SIGKILL) os.unlink(self.pidfile) except (OSError, IOError): pass self.process.wait() self.process.stdout.close() self.process.stderr.close() DulwichClientTestBase.tearDown(self) CompatTestCase.tearDown(self) def _client(self): return client.TCPGitClient(b'localhost') def _build_path(self, path): - return path.encode(sys.getfilesystemencoding()) + return path if sys.platform == 'win32': @expectedFailure def test_fetch_pack_no_side_band_64k(self): DulwichClientTestBase.test_fetch_pack_no_side_band_64k(self) class TestSSHVendor(object): + @staticmethod def run_command(host, command, username=None, port=None): cmd, path = command - cmd = cmd.split('-', 1) + cmd = cmd.split(b'-', 1) p = subprocess.Popen(cmd + [path], bufsize=0, env=get_safe_env(), stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE) return client.SubprocessWrapper(p) class DulwichMockSSHClientTest(CompatTestCase, DulwichClientTestBase): def setUp(self): CompatTestCase.setUp(self) DulwichClientTestBase.setUp(self) self.real_vendor = client.get_ssh_vendor client.get_ssh_vendor = TestSSHVendor def tearDown(self): DulwichClientTestBase.tearDown(self) CompatTestCase.tearDown(self) client.get_ssh_vendor = self.real_vendor def _client(self): return client.SSHGitClient(b'localhost') def _build_path(self, path): - return self.gitroot + path + return self.gitroot.encode(sys.getfilesystemencoding()) + path class DulwichSubprocessClientTest(CompatTestCase, DulwichClientTestBase): def setUp(self): CompatTestCase.setUp(self) DulwichClientTestBase.setUp(self) def tearDown(self): DulwichClientTestBase.tearDown(self) CompatTestCase.tearDown(self) def _client(self): return client.SubprocessGitClient(stderr=subprocess.PIPE) def _build_path(self, path): - return self.gitroot + path + return self.gitroot.encode(sys.getfilesystemencoding()) + 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 = 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.get("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.get('content-length') if length: env['CONTENT_LENGTH'] = length referer = self.headers.get('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.get('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 = ['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 self.server_name = "localhost" def get_url(self): return 'http://%s:%s/' % (self.server_name, self.server_port) @skipIfPY3 class DulwichHttpClientTest(CompatTestCase, DulwichClientTestBase): min_git_version = (1, 7, 0, 2) 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() run_git_or_fail(['config', 'http.uploadpack', 'true'], cwd=self.dest) run_git_or_fail(['config', 'http.receivepack', 'true'], cwd=self.dest) def tearDown(self): DulwichClientTestBase.tearDown(self) CompatTestCase.tearDown(self) self._httpd.shutdown() self._httpd.socket.close() def _client(self): return client.HttpGitClient(self._httpd.get_url()) def _build_path(self, path): return path def test_archive(self): raise SkipTest("exporting archives not supported over http") diff --git a/dulwich/tests/test_client.py b/dulwich/tests/test_client.py index 924f1eb5..90c86444 100644 --- a/dulwich/tests/test_client.py +++ b/dulwich/tests/test_client.py @@ -1,662 +1,662 @@ # test_client.py -- Tests for the git protocol, client side # Copyright (C) 2009 Jelmer Vernooij # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; version 2 # or (at your option) any later version of the License. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. from contextlib import closing from io import BytesIO import sys import shutil import tempfile from dulwich import ( client, ) from dulwich.client import ( LocalGitClient, TraditionalGitClient, TCPGitClient, SubprocessGitClient, SSHGitClient, HttpGitClient, ReportStatusParser, SendPackError, UpdateRefsError, get_transport_and_path, get_transport_and_path_from_url, ) from dulwich.tests import ( TestCase, ) from dulwich.protocol import ( TCP_GIT_PORT, Protocol, ) from dulwich.pack import ( write_pack_objects, ) from dulwich.objects import ( Commit, Tree ) from dulwich.repo import ( MemoryRepo, Repo, ) from dulwich.tests import skipIf from dulwich.tests.utils import ( open_repo, tear_down_repo, ) class DummyClient(TraditionalGitClient): def __init__(self, can_read, read, write): self.can_read = can_read self.read = read self.write = write TraditionalGitClient.__init__(self) def _connect(self, service, path): return Protocol(self.read, self.write), self.can_read # TODO(durin42): add unit-level tests of GitClient class GitClientTests(TestCase): def setUp(self): super(GitClientTests, self).setUp() self.rout = BytesIO() self.rin = BytesIO() self.client = DummyClient(lambda x: True, self.rin.read, self.rout.write) def test_caps(self): self.assertEqual(set([b'multi_ack', b'side-band-64k', b'ofs-delta', b'thin-pack', b'multi_ack_detailed']), set(self.client._fetch_capabilities)) self.assertEqual(set([b'ofs-delta', b'report-status', b'side-band-64k']), set(self.client._send_capabilities)) def test_archive_ack(self): self.rin.write( b'0009NACK\n' b'0000') self.rin.seek(0) self.client.archive(b'bla', b'HEAD', None, None) self.assertEqual(self.rout.getvalue(), b'0011argument HEAD0000') def test_fetch_empty(self): self.rin.write(b'0000') self.rin.seek(0) self.client.fetch_pack(b'/', lambda heads: [], None, None) def test_fetch_pack_none(self): self.rin.write( b'008855dcc6bf963f922e1ed5c4bbaaefcfacef57b1d7 HEAD.multi_ack ' b'thin-pack side-band side-band-64k ofs-delta shallow no-progress ' b'include-tag\n' b'0000') self.rin.seek(0) self.client.fetch_pack(b'bla', lambda heads: [], None, None, None) self.assertEqual(self.rout.getvalue(), b'0000') def test_send_pack_no_sideband64k_with_update_ref_error(self): # No side-bank-64k reported by server shouldn't try to parse # side band data pkts = [b'55dcc6bf963f922e1ed5c4bbaaefcfacef57b1d7 capabilities^{}' b'\x00 report-status delete-refs ofs-delta\n', b'', b"unpack ok", b"ng refs/foo/bar pre-receive hook declined", b''] for pkt in pkts: if pkt == b'': self.rin.write(b"0000") else: self.rin.write(("%04x" % (len(pkt)+4)).encode('ascii') + pkt) self.rin.seek(0) tree = Tree() commit = Commit() commit.tree = tree commit.parents = [] commit.author = commit.committer = b'test user' commit.commit_time = commit.author_time = 1174773719 commit.commit_timezone = commit.author_timezone = 0 commit.encoding = b'UTF-8' commit.message = b'test message' def determine_wants(refs): return {b'refs/foo/bar': commit.id, } def generate_pack_contents(have, want): return [(commit, None), (tree, ''), ] self.assertRaises(UpdateRefsError, self.client.send_pack, "blah", determine_wants, generate_pack_contents) def test_send_pack_none(self): self.rin.write( b'0078310ca9477129b8586fa2afc779c1f57cf64bba6c ' b'refs/heads/master\x00 report-status delete-refs ' b'side-band-64k quiet ofs-delta\n' b'0000') self.rin.seek(0) def determine_wants(refs): return { b'refs/heads/master': b'310ca9477129b8586fa2afc779c1f57cf64bba6c' } def generate_pack_contents(have, want): return {} self.client.send_pack(b'/', determine_wants, generate_pack_contents) self.assertEqual(self.rout.getvalue(), b'0000') def test_send_pack_delete_only(self): self.rin.write( b'0063310ca9477129b8586fa2afc779c1f57cf64bba6c ' b'refs/heads/master\x00report-status delete-refs ofs-delta\n' b'0000000eunpack ok\n' b'0019ok refs/heads/master\n' b'0000') self.rin.seek(0) def determine_wants(refs): return {b'refs/heads/master': b'0' * 40} def generate_pack_contents(have, want): return {} self.client.send_pack(b'/', determine_wants, generate_pack_contents) self.assertIn( self.rout.getvalue(), [b'007f310ca9477129b8586fa2afc779c1f57cf64bba6c ' b'0000000000000000000000000000000000000000 ' b'refs/heads/master\x00report-status ofs-delta0000', b'007f310ca9477129b8586fa2afc779c1f57cf64bba6c ' b'0000000000000000000000000000000000000000 ' b'refs/heads/master\x00ofs-delta report-status0000']) def test_send_pack_new_ref_only(self): self.rin.write( b'0063310ca9477129b8586fa2afc779c1f57cf64bba6c ' b'refs/heads/master\x00report-status delete-refs ofs-delta\n' b'0000000eunpack ok\n' b'0019ok refs/heads/blah12\n' b'0000') self.rin.seek(0) def determine_wants(refs): return { b'refs/heads/blah12': b'310ca9477129b8586fa2afc779c1f57cf64bba6c', b'refs/heads/master': b'310ca9477129b8586fa2afc779c1f57cf64bba6c' } def generate_pack_contents(have, want): return {} f = BytesIO() write_pack_objects(f, {}) self.client.send_pack('/', determine_wants, generate_pack_contents) self.assertIn( self.rout.getvalue(), [b'007f0000000000000000000000000000000000000000 ' b'310ca9477129b8586fa2afc779c1f57cf64bba6c ' b'refs/heads/blah12\x00report-status ofs-delta0000' + f.getvalue(), b'007f0000000000000000000000000000000000000000 ' b'310ca9477129b8586fa2afc779c1f57cf64bba6c ' b'refs/heads/blah12\x00ofs-delta report-status0000' + f.getvalue()]) def test_send_pack_new_ref(self): self.rin.write( b'0064310ca9477129b8586fa2afc779c1f57cf64bba6c ' b'refs/heads/master\x00 report-status delete-refs ofs-delta\n' b'0000000eunpack ok\n' b'0019ok refs/heads/blah12\n' b'0000') self.rin.seek(0) tree = Tree() commit = Commit() commit.tree = tree commit.parents = [] commit.author = commit.committer = b'test user' commit.commit_time = commit.author_time = 1174773719 commit.commit_timezone = commit.author_timezone = 0 commit.encoding = b'UTF-8' commit.message = b'test message' def determine_wants(refs): return { b'refs/heads/blah12': commit.id, b'refs/heads/master': b'310ca9477129b8586fa2afc779c1f57cf64bba6c' } def generate_pack_contents(have, want): return [(commit, None), (tree, b''), ] f = BytesIO() write_pack_objects(f, generate_pack_contents(None, None)) self.client.send_pack(b'/', determine_wants, generate_pack_contents) self.assertIn( self.rout.getvalue(), [b'007f0000000000000000000000000000000000000000 ' + commit.id + b' refs/heads/blah12\x00report-status ofs-delta0000' + f.getvalue(), b'007f0000000000000000000000000000000000000000 ' + commit.id + b' refs/heads/blah12\x00ofs-delta report-status0000' + f.getvalue()]) def test_send_pack_no_deleteref_delete_only(self): pkts = [b'310ca9477129b8586fa2afc779c1f57cf64bba6c refs/heads/master' b'\x00 report-status ofs-delta\n', b'', b''] for pkt in pkts: if pkt == b'': self.rin.write(b"0000") else: self.rin.write(("%04x" % (len(pkt)+4)).encode('ascii') + pkt) self.rin.seek(0) def determine_wants(refs): return {b'refs/heads/master': b'0' * 40} def generate_pack_contents(have, want): return {} self.assertRaises(UpdateRefsError, self.client.send_pack, b"/", determine_wants, generate_pack_contents) self.assertEqual(self.rout.getvalue(), b'0000') class TestGetTransportAndPath(TestCase): def test_tcp(self): c, path = get_transport_and_path('git://foo.com/bar/baz') self.assertTrue(isinstance(c, TCPGitClient)) self.assertEqual('foo.com', c._host) self.assertEqual(TCP_GIT_PORT, c._port) self.assertEqual('/bar/baz', path) def test_tcp_port(self): c, path = get_transport_and_path('git://foo.com:1234/bar/baz') self.assertTrue(isinstance(c, TCPGitClient)) self.assertEqual('foo.com', c._host) self.assertEqual(1234, c._port) self.assertEqual('/bar/baz', path) def test_ssh_explicit(self): c, path = get_transport_and_path('git+ssh://foo.com/bar/baz') self.assertTrue(isinstance(c, SSHGitClient)) self.assertEqual('foo.com', c.host) self.assertEqual(None, c.port) self.assertEqual(None, c.username) self.assertEqual('bar/baz', path) def test_ssh_port_explicit(self): c, path = get_transport_and_path( 'git+ssh://foo.com:1234/bar/baz') self.assertTrue(isinstance(c, SSHGitClient)) self.assertEqual('foo.com', c.host) self.assertEqual(1234, c.port) self.assertEqual('bar/baz', path) def test_ssh_abspath_explicit(self): c, path = get_transport_and_path('git+ssh://foo.com//bar/baz') self.assertTrue(isinstance(c, SSHGitClient)) self.assertEqual('foo.com', c.host) self.assertEqual(None, c.port) self.assertEqual(None, c.username) self.assertEqual('/bar/baz', path) def test_ssh_port_abspath_explicit(self): c, path = get_transport_and_path( 'git+ssh://foo.com:1234//bar/baz') self.assertTrue(isinstance(c, SSHGitClient)) self.assertEqual('foo.com', c.host) self.assertEqual(1234, c.port) self.assertEqual('/bar/baz', path) def test_ssh_implicit(self): c, path = get_transport_and_path('foo:/bar/baz') self.assertTrue(isinstance(c, SSHGitClient)) self.assertEqual('foo', c.host) self.assertEqual(None, c.port) self.assertEqual(None, c.username) self.assertEqual('/bar/baz', path) def test_ssh_host(self): c, path = get_transport_and_path('foo.com:/bar/baz') self.assertTrue(isinstance(c, SSHGitClient)) self.assertEqual('foo.com', c.host) self.assertEqual(None, c.port) self.assertEqual(None, c.username) self.assertEqual('/bar/baz', path) def test_ssh_user_host(self): c, path = get_transport_and_path('user@foo.com:/bar/baz') self.assertTrue(isinstance(c, SSHGitClient)) self.assertEqual('foo.com', c.host) self.assertEqual(None, c.port) self.assertEqual('user', c.username) self.assertEqual('/bar/baz', path) def test_ssh_relpath(self): c, path = get_transport_and_path('foo:bar/baz') self.assertTrue(isinstance(c, SSHGitClient)) self.assertEqual('foo', c.host) self.assertEqual(None, c.port) self.assertEqual(None, c.username) self.assertEqual('bar/baz', path) def test_ssh_host_relpath(self): c, path = get_transport_and_path('foo.com:bar/baz') self.assertTrue(isinstance(c, SSHGitClient)) self.assertEqual('foo.com', c.host) self.assertEqual(None, c.port) self.assertEqual(None, c.username) self.assertEqual('bar/baz', path) def test_ssh_user_host_relpath(self): c, path = get_transport_and_path('user@foo.com:bar/baz') self.assertTrue(isinstance(c, SSHGitClient)) self.assertEqual('foo.com', c.host) self.assertEqual(None, c.port) self.assertEqual('user', c.username) self.assertEqual('bar/baz', path) def test_local(self): c, path = get_transport_and_path('foo.bar/baz') self.assertTrue(isinstance(c, LocalGitClient)) self.assertEqual('foo.bar/baz', path) @skipIf(sys.platform != 'win32', 'Behaviour only happens on windows.') def test_local_abs_windows_path(self): c, path = get_transport_and_path('C:\\foo.bar\\baz') self.assertTrue(isinstance(c, LocalGitClient)) self.assertEqual('C:\\foo.bar\\baz', path) def test_error(self): # Need to use a known urlparse.uses_netloc URL scheme to get the # expected parsing of the URL on Python versions less than 2.6.5 c, path = get_transport_and_path('prospero://bar/baz') self.assertTrue(isinstance(c, SSHGitClient)) def test_http(self): url = 'https://github.com/jelmer/dulwich' c, path = get_transport_and_path(url) self.assertTrue(isinstance(c, HttpGitClient)) self.assertEqual('/jelmer/dulwich', path) class TestGetTransportAndPathFromUrl(TestCase): def test_tcp(self): c, path = get_transport_and_path_from_url('git://foo.com/bar/baz') self.assertTrue(isinstance(c, TCPGitClient)) self.assertEqual('foo.com', c._host) self.assertEqual(TCP_GIT_PORT, c._port) self.assertEqual('/bar/baz', path) def test_tcp_port(self): c, path = get_transport_and_path_from_url('git://foo.com:1234/bar/baz') self.assertTrue(isinstance(c, TCPGitClient)) self.assertEqual('foo.com', c._host) self.assertEqual(1234, c._port) self.assertEqual('/bar/baz', path) def test_ssh_explicit(self): c, path = get_transport_and_path_from_url('git+ssh://foo.com/bar/baz') self.assertTrue(isinstance(c, SSHGitClient)) self.assertEqual('foo.com', c.host) self.assertEqual(None, c.port) self.assertEqual(None, c.username) self.assertEqual('bar/baz', path) def test_ssh_port_explicit(self): c, path = get_transport_and_path_from_url( 'git+ssh://foo.com:1234/bar/baz') self.assertTrue(isinstance(c, SSHGitClient)) self.assertEqual('foo.com', c.host) self.assertEqual(1234, c.port) self.assertEqual('bar/baz', path) def test_ssh_abspath_explicit(self): c, path = get_transport_and_path_from_url('git+ssh://foo.com//bar/baz') self.assertTrue(isinstance(c, SSHGitClient)) self.assertEqual('foo.com', c.host) self.assertEqual(None, c.port) self.assertEqual(None, c.username) self.assertEqual('/bar/baz', path) def test_ssh_port_abspath_explicit(self): c, path = get_transport_and_path_from_url( 'git+ssh://foo.com:1234//bar/baz') self.assertTrue(isinstance(c, SSHGitClient)) self.assertEqual('foo.com', c.host) self.assertEqual(1234, c.port) self.assertEqual('/bar/baz', path) def test_ssh_host_relpath(self): self.assertRaises(ValueError, get_transport_and_path_from_url, 'foo.com:bar/baz') def test_ssh_user_host_relpath(self): self.assertRaises(ValueError, get_transport_and_path_from_url, 'user@foo.com:bar/baz') def test_local_path(self): self.assertRaises(ValueError, get_transport_and_path_from_url, 'foo.bar/baz') def test_error(self): # Need to use a known urlparse.uses_netloc URL scheme to get the # expected parsing of the URL on Python versions less than 2.6.5 self.assertRaises(ValueError, get_transport_and_path_from_url, 'prospero://bar/baz') def test_http(self): url = 'https://github.com/jelmer/dulwich' c, path = get_transport_and_path_from_url(url) self.assertTrue(isinstance(c, HttpGitClient)) self.assertEqual('/jelmer/dulwich', path) def test_file(self): c, path = get_transport_and_path_from_url('file:///home/jelmer/foo') self.assertTrue(isinstance(c, LocalGitClient)) self.assertEqual('/home/jelmer/foo', path) class TestSSHVendor(object): def __init__(self): self.host = None self.command = "" self.username = None self.port = None def run_command(self, host, command, username=None, port=None): if (type(command) is not list or not all([isinstance(b, bytes) for b in command])): raise TypeError(command) self.host = host self.command = command self.username = username self.port = port class Subprocess: pass setattr(Subprocess, 'read', lambda: None) setattr(Subprocess, 'write', lambda: None) setattr(Subprocess, 'close', lambda: None) setattr(Subprocess, 'can_read', lambda: None) return Subprocess() class SSHGitClientTests(TestCase): def setUp(self): super(SSHGitClientTests, self).setUp() self.server = TestSSHVendor() self.real_vendor = client.get_ssh_vendor client.get_ssh_vendor = lambda: self.server self.client = SSHGitClient('git.samba.org') def tearDown(self): super(SSHGitClientTests, self).tearDown() client.get_ssh_vendor = self.real_vendor def test_default_command(self): - self.assertEqual(['git-upload-pack'], + self.assertEqual([b'git-upload-pack'], self.client._get_cmd_path(b'upload-pack')) def test_alternative_command_path(self): self.client.alternative_paths[b'upload-pack'] = ( b'/usr/lib/git/git-upload-pack') - self.assertEqual(['/usr/lib/git/git-upload-pack'], + self.assertEqual([b'/usr/lib/git/git-upload-pack'], self.client._get_cmd_path(b'upload-pack')) def test_alternative_command_path_spaces(self): self.client.alternative_paths[b'upload-pack'] = ( b'/usr/lib/git/git-upload-pack -ibla') - self.assertEqual(['/usr/lib/git/git-upload-pack', '-ibla'], + self.assertEqual([b'/usr/lib/git/git-upload-pack', b'-ibla'], self.client._get_cmd_path(b'upload-pack')) def test_connect(self): server = self.server client = self.client client.username = b"username" client.port = 1337 - client._connect(b"command", "/path/to/repo") + client._connect(b"command", b"/path/to/repo") self.assertEqual(b"username", server.username) self.assertEqual(1337, server.port) - self.assertEqual(["git-command", "/path/to/repo"], server.command) + self.assertEqual([b"git-command", b"/path/to/repo"], server.command) - client._connect(b"relative-command", "/~/path/to/repo") - self.assertEqual(["git-relative-command", "~/path/to/repo"], + client._connect(b"relative-command", b"/~/path/to/repo") + self.assertEqual([b"git-relative-command", b"~/path/to/repo"], server.command) class ReportStatusParserTests(TestCase): def test_invalid_pack(self): parser = ReportStatusParser() parser.handle_packet(b"unpack error - foo bar") parser.handle_packet(b"ok refs/foo/bar") parser.handle_packet(None) self.assertRaises(SendPackError, parser.check) def test_update_refs_error(self): parser = ReportStatusParser() parser.handle_packet(b"unpack ok") parser.handle_packet(b"ng refs/foo/bar need to pull") parser.handle_packet(None) self.assertRaises(UpdateRefsError, parser.check) def test_ok(self): parser = ReportStatusParser() parser.handle_packet(b"unpack ok") parser.handle_packet(b"ok refs/foo/bar") parser.handle_packet(None) parser.check() class LocalGitClientTests(TestCase): def test_fetch_into_empty(self): c = LocalGitClient() t = MemoryRepo() s = open_repo('a.git') self.addCleanup(tear_down_repo, s) self.assertEqual(s.get_refs(), c.fetch(s.path, t)) def test_fetch_empty(self): c = LocalGitClient() s = open_repo('a.git') self.addCleanup(tear_down_repo, s) out = BytesIO() walker = {} c.fetch_pack(s.path, lambda heads: [], graph_walker=walker, pack_data=out.write) self.assertEqual(b"PACK\x00\x00\x00\x02\x00\x00\x00\x00\x02\x9d\x08" b"\x82;\xd8\xa8\xea\xb5\x10\xadj\xc7\\\x82<\xfd>\xd3\x1e", out.getvalue()) def test_fetch_pack_none(self): c = LocalGitClient() s = open_repo('a.git') self.addCleanup(tear_down_repo, s) out = BytesIO() walker = MemoryRepo().get_graph_walker() c.fetch_pack(s.path, lambda heads: [b"a90fa2d900a17e99b433217e988c4eb4a2e9a097"], graph_walker=walker, pack_data=out.write) # Hardcoding is not ideal, but we'll fix that some other day.. self.assertTrue(out.getvalue().startswith(b'PACK\x00\x00\x00\x02\x00\x00\x00\x07')) def test_send_pack_without_changes(self): local = open_repo('a.git') self.addCleanup(tear_down_repo, local) target = open_repo('a.git') self.addCleanup(tear_down_repo, target) self.send_and_verify(b"master", local, target) def test_send_pack_with_changes(self): local = open_repo('a.git') self.addCleanup(tear_down_repo, local) target_path = tempfile.mkdtemp() self.addCleanup(shutil.rmtree, target_path) with closing(Repo.init_bare(target_path)) as target: self.send_and_verify(b"master", local, target) def test_get_refs(self): local = open_repo('refs.git') self.addCleanup(tear_down_repo, local) client = LocalGitClient() refs = client.get_refs(local.path) self.assertDictEqual(local.refs.as_dict(), refs) def send_and_verify(self, branch, local, target): client = LocalGitClient() ref_name = b"refs/heads/" + branch new_refs = client.send_pack(target.path, lambda _: { ref_name: local.refs[ref_name] }, local.object_store.generate_pack_contents) self.assertEqual(local.refs[ref_name], new_refs[ref_name]) for name, sha in new_refs.items(): self.assertEqual(new_refs[name], target.refs[name]) obj_local = local.get_object(new_refs[ref_name]) obj_target = target.get_object(new_refs[ref_name]) self.assertEqual(obj_local, obj_target)