diff --git a/dulwich/client.py b/dulwich/client.py index 5d17b067..4e9978a0 100644 --- a/dulwich/client.py +++ b/dulwich/client.py @@ -1,1485 +1,1484 @@ # client.py -- Implementation of the client side git protocols # Copyright (C) 2008-2013 Jelmer Vernooij # # Dulwich is dual-licensed under the Apache License, Version 2.0 and the GNU # General Public License as public by the Free Software Foundation; version 2.0 # or (at your option) any later version. You can redistribute it and/or # modify it under the terms of either of these two licenses. # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # # You should have received a copy of the licenses; if not, see # for a copy of the GNU General Public License # and for a copy of the Apache # License, Version 2.0. # """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 * quiet * report-status * delete-refs Known capabilities that are not supported: * shallow * no-progress * include-tag """ -from StringIO import StringIO from contextlib import closing from io import BytesIO, BufferedReader import gzip import select import socket import subprocess import sys try: from urllib import quote as urlquote from urllib import unquote as urlunquote except ImportError: from urllib.parse import quote as urlquote from urllib.parse import unquote as urlunquote try: import urllib2 import urlparse except ImportError: import urllib.request as urllib2 import urllib.parse as urlparse import dulwich from dulwich.errors import ( GitProtocolError, NotGitRepository, SendPackError, UpdateRefsError, ) from dulwich.protocol import ( _RBUFSIZE, agent_string, capability_agent, extract_capability_names, CAPABILITY_AGENT, CAPABILITY_DELETE_REFS, CAPABILITY_MULTI_ACK, CAPABILITY_MULTI_ACK_DETAILED, CAPABILITY_OFS_DELTA, CAPABILITY_QUIET, CAPABILITY_REPORT_STATUS, CAPABILITY_SYMREF, CAPABILITY_SIDE_BAND_64K, CAPABILITY_THIN_PACK, CAPABILITIES_REF, KNOWN_RECEIVE_CAPABILITIES, KNOWN_UPLOAD_CAPABILITIES, 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, parse_capability, ) 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 def _win32_peek_avail(handle): """Wrapper around PeekNamedPipe to check how many bytes are available.""" from ctypes import byref, wintypes, windll c_avail = wintypes.DWORD() c_message = wintypes.DWORD() success = windll.kernel32.PeekNamedPipe( handle, None, 0, None, byref(c_avail), byref(c_message)) if not success: raise OSError(wintypes.GetLastError()) return c_avail.value COMMON_CAPABILITIES = [CAPABILITY_OFS_DELTA, CAPABILITY_SIDE_BAND_64K] UPLOAD_CAPABILITIES = ([CAPABILITY_THIN_PACK, CAPABILITY_MULTI_ACK, CAPABILITY_MULTI_ACK_DETAILED] + COMMON_CAPABILITIES) RECEIVE_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([ refname.decode('ascii') for refname in ref_status if refname 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([]) if refs == {CAPABILITIES_REF: ZERO_SHA}: refs = {} return refs, set(server_capabilities) class FetchPackResult(object): """Result of a fetch-pack operation. :var refs: Dictionary with all remote refs :var symrefs: Dictionary with remote symrefs :var agent: User agent string """ _FORWARDED_ATTRS = [ 'clear', 'copy', 'fromkeys', 'get', 'has_key', 'items', 'iteritems', 'iterkeys', 'itervalues', 'keys', 'pop', 'popitem', 'setdefault', 'update', 'values', 'viewitems', 'viewkeys', 'viewvalues'] def __init__(self, refs, symrefs, agent): self.refs = refs self.symrefs = symrefs self.agent = agent def _warn_deprecated(self): import warnings warnings.warn( "Use FetchPackResult.refs instead.", DeprecationWarning, stacklevel=3) def __eq__(self, other): if isinstance(other, dict): self._warn_deprecated() return (self.refs == other) return (self.refs == other.refs and self.symrefs == other.symrefs and self.agent == other.agent) def __contains__(self, name): self._warn_deprecated() return name in self.refs def __getitem__(self, name): self._warn_deprecated() return self.refs[name] def __len__(self): self._warn_deprecated() return len(self.refs) def __iter__(self): self._warn_deprecated() return iter(self.refs) def __getattribute__(self, name): if name in type(self)._FORWARDED_ATTRS: self._warn_deprecated() return getattr(self.refs, name) return super(FetchPackResult, self).__getattribute__(name) # 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, quiet=False): """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(UPLOAD_CAPABILITIES) self._fetch_capabilities.add(capability_agent()) self._send_capabilities = set(RECEIVE_CAPABILITIES) self._send_capabilities.add(capability_agent()) if quiet: self._send_capabilities.add(CAPABILITY_QUIET) if not thin_packs: self._fetch_capabilities.remove(CAPABILITY_THIN_PACK) def get_url(self, path): """Retrieves full url to given path. :param path: Repository path (as string) :return: Url to path (as string) """ raise NotImplementedError(self.get_url) @classmethod def from_parsedurl(cls, parsedurl, **kwargs): """Create an instance of this client from a urlparse.parsed object. :param parsedurl: Result of urlparse.urlparse() :return: A `GitClient` object """ raise NotImplementedError(cls.from_parsedurl) def send_pack(self, path, update_refs, generate_pack_contents, progress=None, write_pack=write_pack_objects): """Upload a pack to a remote repository. :param path: Repository path (as bytestring) :param update_refs: Function to determine changes to remote refs. Receive dict with existing remote refs, returns dict with changed refs (name -> sha, where sha=ZERO_SHA for deletions) :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 :return: new_refs dictionary containing the changes that were made {refname: new_ref}, including deleted refs. """ 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 (as bytestring) :param target: Target repository to fetch into :param determine_wants: Optional function to determine what refs to fetch. Receives dictionary of name->sha, should return list of shas to fetch. Defaults to all shas. :param progress: Optional progress function :return: Dictionary with all remote refs (not just those fetched) """ if determine_wants is None: determine_wants = target.object_store.determine_wants_all if CAPABILITY_THIN_PACK in self._fetch_capabilities: # TODO(jelmer): Avoid reading entire file into memory and # only processing it after the whole file has been fetched. f = BytesIO() def commit(): if f.tell(): f.seek(0) target.object_store.add_thin_pack(f.read, None) def abort(): pass else: 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 path: Remote path to fetch from :param determine_wants: Function determine what refs to fetch. Receives dictionary of name->sha, should return list of shas 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: FetchPackResult object """ 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. (as bytestring) """ 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([ refname for refname in ref_status if refname 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: Refs to change :return: (have, want) tuple """ want = [] have = [x for x in old_refs.values() if not x == ZERO_SHA] sent_capabilities = False for refname in new_refs: if not isinstance(refname, bytes): raise TypeError('refname is not a bytestring: %r' % refname) old_sha1 = old_refs.get(refname, ZERO_SHA) if not isinstance(old_sha1, bytes): raise TypeError('old sha1 for %s is not a bytestring: %r' % (refname, old_sha1)) new_sha1 = new_refs.get(refname, ZERO_SHA) if not isinstance(new_sha1, bytes): raise TypeError('old sha1 for %s is not a bytestring %r' % (refname, new_sha1)) 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 _negotiate_receive_pack_capabilities(self, server_capabilities): negotiated_capabilities = ( self._send_capabilities & server_capabilities) unknown_capabilities = ( # noqa: F841 extract_capability_names(server_capabilities) - KNOWN_RECEIVE_CAPABILITIES) # TODO(jelmer): warn about unknown capabilities return negotiated_capabilities 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 CAPABILITY_SIDE_BAND_64K in capabilities: if progress is None: def progress(x): pass 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 _negotiate_upload_pack_capabilities(self, server_capabilities): unknown_capabilities = ( # noqa: F841 extract_capability_names(server_capabilities) - KNOWN_UPLOAD_CAPABILITIES) # TODO(jelmer): warn about unknown capabilities symrefs = {} agent = None for capability in server_capabilities: k, v = parse_capability(capability) if k == CAPABILITY_SYMREF: (src, dst) = v.split(b':', 1) symrefs[src] = dst if k == CAPABILITY_AGENT: agent = v negotiated_capabilities = ( self._fetch_capabilities & server_capabilities) return (negotiated_capabilities, symrefs, agent) 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 def progress(x): pass 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.""" DEFAULT_ENCODING = 'utf-8' def __init__(self, path_encoding=DEFAULT_ENCODING, **kwargs): self._remote_path_encoding = path_encoding super(TraditionalGitClient, self).__init__(**kwargs) 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. (as bytestirng) """ raise NotImplementedError() def send_pack(self, path, update_refs, generate_pack_contents, progress=None, write_pack=write_pack_objects): """Upload a pack to a remote repository. :param path: Repository path (as bytestring) :param update_refs: Function to determine changes to remote refs. Receive dict with existing remote refs, returns dict with changed refs (name -> sha, where sha=ZERO_SHA for deletions) :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 :return: new_refs dictionary containing the changes that were made {refname: new_ref}, including deleted refs. """ proto, unused_can_read = self._connect(b'receive-pack', path) with proto: old_refs, server_capabilities = read_pkt_refs(proto) negotiated_capabilities = \ self._negotiate_receive_pack_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 = update_refs(dict(old_refs)) except: proto.write_pkt_line(None) raise if CAPABILITY_DELETE_REFS not 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 set(new_refs.items()).issubset(set(old_refs.items()))): 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 path: Remote path to fetch from :param determine_wants: Function determine what refs to fetch. Receives dictionary of name->sha, should return list of shas 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: FetchPackResult object """ proto, can_read = self._connect(b'upload-pack', path) with proto: refs, server_capabilities = read_pkt_refs(proto) negotiated_capabilities, symrefs, agent = ( self._negotiate_upload_pack_capabilities( server_capabilities)) if refs is None: proto.write_pkt_line(None) return FetchPackResult(refs, symrefs, agent) 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 FetchPackResult(refs, symrefs, agent) 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 FetchPackResult(refs, symrefs, agent) 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) proto.write_pkt_line(None) 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, **kwargs): if port is None: port = TCP_GIT_PORT self._host = host self._port = port super(TCPGitClient, self).__init__(**kwargs) @classmethod def from_parsedurl(cls, parsedurl, **kwargs): return cls(parsedurl.hostname, port=parsedurl.port, **kwargs) def get_url(self, path): netloc = self._host if self._port is not None and self._port != TCP_GIT_PORT: netloc += ":%d" % self._port return urlparse.urlunsplit(("git", netloc, path, '', '')) def _connect(self, cmd, path): if not isinstance(cmd, bytes): raise TypeError(cmd) if not isinstance(path, bytes): path = path.encode(self._remote_path_encoding) 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:] # TODO(jelmer): Alternative to ascii? proto.send_cmd( b'git-' + cmd, path, b'host=' + self._host.encode('ascii')) 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 sys.platform == 'win32': from msvcrt import get_osfhandle handle = get_osfhandle(self.proc.stdout.fileno()) return _win32_peek_avail(handle) != 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, **kwargs): self._connection = None self._stderr = None self._stderr = kwargs.get('stderr') if 'stderr' in kwargs: del kwargs['stderr'] super(SubprocessGitClient, self).__init__(**kwargs) @classmethod def from_parsedurl(cls, parsedurl, **kwargs): return cls(**kwargs) git_command = None def _connect(self, service, path): if not isinstance(service, bytes): raise TypeError(service) if isinstance(path, bytes): path = path.decode(self._remote_path_encoding) 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, config=None): """Create a new LocalGitClient 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 # Ignore the thin_packs argument def get_url(self, path): return urlparse.urlunsplit(('file', '', path, '', '')) @classmethod def from_parsedurl(cls, parsedurl, **kwargs): return cls(**kwargs) @classmethod def _open_repo(cls, path): from dulwich.repo import Repo if not isinstance(path, str): path = path.decode(sys.getfilesystemencoding()) return closing(Repo(path)) def send_pack(self, path, update_refs, generate_pack_contents, progress=None, write_pack=write_pack_objects): """Upload a pack to a remote repository. :param path: Repository path (as bytestring) :param update_refs: Function to determine changes to remote refs. Receive dict with existing remote refs, returns dict with changed refs (name -> sha, where sha=ZERO_SHA for deletions) :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 :return: new_refs dictionary containing the changes that were made {refname: new_ref}, including deleted refs. """ if not progress: def progress(x): pass with self._open_repo(path) as target: old_refs = target.get_refs() new_refs = update_refs(dict(old_refs)) have = [sha1 for sha1 in old_refs.values() if sha1 != ZERO_SHA] want = [] for refname, new_sha1 in new_refs.items(): if (new_sha1 not in have and new_sha1 not in want and new_sha1 != ZERO_SHA): want.append(new_sha1) if (not want and set(new_refs.items()).issubset(set(old_refs.items()))): return new_refs target.object_store.add_objects(generate_pack_contents(have, want)) for refname, new_sha1 in new_refs.items(): old_sha1 = old_refs.get(refname, ZERO_SHA) if new_sha1 != ZERO_SHA: if not target.refs.set_if_equals( refname, old_sha1, new_sha1): progress('unable to set %s to %s' % (refname, new_sha1)) else: if not target.refs.remove_if_equals(refname, old_sha1): progress('unable to remove %s' % refname) return new_refs def fetch(self, path, target, determine_wants=None, progress=None): """Fetch into a target repository. :param path: Path to fetch from (as bytestring) :param target: Target repository to fetch into :param determine_wants: Optional function determine what refs to fetch. Receives dictionary of name->sha, should return list of shas to fetch. Defaults to all shas. :param progress: Optional progress function :return: Dictionary with all remote refs (not just those fetched) """ with self._open_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 path: Remote path to fetch from :param determine_wants: Function determine what refs to fetch. Receives dictionary of name->sha, should return list of shas 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: FetchPackResult object """ with self._open_repo(path) as r: objects_iter = r.fetch_objects( determine_wants, graph_walker, progress) symrefs = r.refs.get_symrefs() agent = agent_string() # 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 FetchPackResult(None, symrefs, agent) write_pack_objects(ProtocolFile(None, pack_data), objects_iter) return FetchPackResult(r.get_refs(), symrefs, agent) def get_refs(self, path): """Retrieve the current refs from a git smart server.""" with self._open_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): # This function was deprecated in 0.9.1 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): # 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], bufsize=0, stdin=subprocess.PIPE, stdout=subprocess.PIPE) return SubprocessWrapper(proc) def ParamikoSSHVendor(**kwargs): import warnings warnings.warn( "ParamikoSSHVendor has been moved to dulwich.contrib.paramiko_vendor.", DeprecationWarning) from dulwich.contrib.paramiko_vendor import ParamikoSSHVendor return ParamikoSSHVendor(**kwargs) # Can be overridden by users get_ssh_vendor = SubprocessSSHVendor class SSHGitClient(TraditionalGitClient): def __init__(self, host, port=None, username=None, vendor=None, config=None, **kwargs): self.host = host self.port = port self.username = username super(SSHGitClient, self).__init__(**kwargs) self.alternative_paths = {} if vendor is not None: self.ssh_vendor = vendor else: self.ssh_vendor = get_ssh_vendor() def get_url(self, path): netloc = self.host if self.port is not None: netloc += ":%d" % self.port if self.username is not None: netloc = urlquote(self.username, '@/:') + "@" + netloc return urlparse.urlunsplit(('ssh', netloc, path, '', '')) @classmethod def from_parsedurl(cls, parsedurl, **kwargs): return cls(host=parsedurl.hostname, port=parsedurl.port, username=parsedurl.username, **kwargs) def _get_cmd_path(self, cmd): cmd = self.alternative_paths.get(cmd, b'git-' + cmd) assert isinstance(cmd, bytes) return cmd def _connect(self, cmd, path): if not isinstance(cmd, bytes): raise TypeError(cmd) if isinstance(path, bytes): path = path.decode(self._remote_path_encoding) if path.startswith("/~"): path = path[1:] argv = (self._get_cmd_path(cmd).decode(self._remote_path_encoding) + " '" + path + "'") con = self.ssh_vendor.run_command( 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: try: proxy_server = config.get(b"http", b"proxy") except KeyError: proxy_server = None 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: try: user_agent = config.get(b"http", b"useragent") except KeyError: user_agent = None 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, username=None, password=None, **kwargs): self._base_url = base_url.rstrip("/") + "/" self._username = username self._password = password self.dumb = dumb if opener is None: self.opener = default_urllib2_opener(config) else: self.opener = opener if username is not None: pass_man = urllib2.HTTPPasswordMgrWithDefaultRealm() pass_man.add_password(None, base_url, username, password) self.opener.add_handler(urllib2.HTTPBasicAuthHandler(pass_man)) GitClient.__init__(self, **kwargs) def get_url(self, path): return self._get_url(path).rstrip("/") @classmethod def from_parsedurl(cls, parsedurl, **kwargs): auth, host = urllib2.splituser(parsedurl.netloc) password = parsedurl.password if password is not None: password = urlunquote(password) username = parsedurl.username if username is not None: username = urlunquote(username) # TODO(jelmer): This also strips the username parsedurl = parsedurl._replace(netloc=host) return cls(urlparse.urlunparse(parsedurl), password=password, username=username, **kwargs) def __repr__(self): return "%s(%r, dumb=%r)" % ( type(self).__name__, self._base_url, self.dumb) def _get_url(self, path): if not isinstance(path, str): # TODO(jelmer): this is unrelated to the local filesystem; # This is not necessarily the right encoding to decode the path # with. path = path.decode(sys.getfilesystemencoding()) return urlparse.urljoin(self._base_url, path).rstrip("/") + "/" def _http_request(self, url, headers={}, data=None, allow_compression=False): if headers is None: headers = dict(headers.items()) headers["Pragma"] = "no-cache" if allow_compression: headers["Accept-Encoding"] = "gzip" else: headers["Accept-Encoding"] = "identity" 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 for %s" % (e.code, url)) if resp.info().get('Content-Encoding') == 'gzip': - read = gzip.GzipFile(fileobj=StringIO(resp.read())).read + read = gzip.GzipFile(fileobj=BytesIO(resp.read())).read else: read = resp.read return resp, read def _discover_references(self, service, base_url): assert base_url[-1] == "/" tail = "info/refs" headers = {"Accept": "*/*"} if self.dumb is not False: tail += "?service=%s" % service.decode('ascii') url = urlparse.urljoin(base_url, tail) resp, read = self._http_request(url, headers, allow_compression=True) if url != resp.geturl(): # Something changed (redirect!), so let's update the base URL if not resp.geturl().endswith(tail): raise GitProtocolError( "Redirected from URL %s to URL %s without %s" % ( url, resp.geturl(), tail)) base_url = resp.geturl()[:-len(tail)] try: content_type = resp.info().gettype() except AttributeError: content_type = resp.info().get_content_type() try: self.dumb = (not content_type.startswith("application/x-git-")) if not self.dumb: proto = Protocol(read, None) # The first line should mention the service try: [pkt] = list(proto.read_pkt_seq()) except ValueError: raise GitProtocolError( "unexpected number of packets received") if pkt.rstrip(b'\n') != (b'# service=' + service): raise GitProtocolError( "unexpected first line %r from smart server" % pkt) return read_pkt_refs(proto) + (base_url, ) else: return read_info_refs(resp), set(), base_url 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, read = self._http_request(url, headers, data) try: content_type = resp.info().gettype() except AttributeError: content_type = resp.info().get_content_type() if content_type != ( "application/x-%s-result" % service): raise GitProtocolError("Invalid content-type from server: %s" % content_type) return resp, read def send_pack(self, path, update_refs, generate_pack_contents, progress=None, write_pack=write_pack_objects): """Upload a pack to a remote repository. :param path: Repository path (as bytestring) :param update_refs: Function to determine changes to remote refs. Receive dict with existing remote refs, returns dict with changed refs (name -> sha, where sha=ZERO_SHA for deletions) :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 :return: new_refs dictionary containing the changes that were made {refname: new_ref}, including deleted refs. """ url = self._get_url(path) old_refs, server_capabilities, url = self._discover_references( b"git-receive-pack", url) negotiated_capabilities = self._negotiate_receive_pack_capabilities( server_capabilities) if CAPABILITY_REPORT_STATUS in negotiated_capabilities: self._report_status_parser = ReportStatusParser() new_refs = update_refs(dict(old_refs)) if new_refs is None: # Determine wants function is aborting the push. 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 set(new_refs.items()).issubset(set(old_refs.items())): return new_refs objects = generate_pack_contents(have, want) if len(objects) > 0: write_pack(req_proto.write_file(), objects) resp, read = self._smart_request("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: FetchPackResult object """ url = self._get_url(path) refs, server_capabilities, url = self._discover_references( b"git-upload-pack", url) negotiated_capabilities, symrefs, agent = ( self._negotiate_upload_pack_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 FetchPackResult(refs, symrefs, agent) 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, read = self._smart_request( "git-upload-pack", url, data=req_data.getvalue()) try: resp_proto = Protocol(read, None) self._handle_upload_pack_tail( resp_proto, negotiated_capabilities, graph_walker, pack_data, progress) return FetchPackResult(refs, symrefs, agent) 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 (a unicode string) :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.from_parsedurl(parsed, **kwargs), parsed.path) elif parsed.scheme in ('git+ssh', 'ssh'): return SSHGitClient.from_parsedurl(parsed, **kwargs), parsed.path elif parsed.scheme in ('http', 'https'): return HttpGitClient.from_parsedurl( parsed, config=config, **kwargs), parsed.path elif parsed.scheme == 'file': return default_local_git_client_cls.from_parsedurl( parsed, **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 (a string) :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(':', 1) return SSHGitClient(hostname, **kwargs), path elif ':' in location: # SSH with user@host:foo. user_host, path = location.split(':', 1) if '@' in user_host: user, host = user_host.rsplit('@', 1) else: user = None host = user_host 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/object_store.py b/dulwich/object_store.py index dc38f6fe..8e84e89a 100644 --- a/dulwich/object_store.py +++ b/dulwich/object_store.py @@ -1,1205 +1,1206 @@ # object_store.py -- Object store for git objects # Copyright (C) 2008-2013 Jelmer Vernooij # and others # # Dulwich is dual-licensed under the Apache License, Version 2.0 and the GNU # General Public License as public by the Free Software Foundation; version 2.0 # or (at your option) any later version. You can redistribute it and/or # modify it under the terms of either of these two licenses. # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # # You should have received a copy of the licenses; if not, see # for a copy of the GNU General Public License # and for a copy of the Apache # License, Version 2.0. # """Git object store interfaces and implementation.""" from io import BytesIO import errno from itertools import chain import os import stat import sys import tempfile import time from dulwich.diff_tree import ( tree_changes, walk_trees, ) from dulwich.errors import ( NotTreeError, ) from dulwich.file import GitFile from dulwich.objects import ( Commit, ShaFile, Tag, Tree, ZERO_SHA, hex_to_sha, sha_to_hex, hex_to_filename, S_ISGITLINK, object_class, ) from dulwich.pack import ( Pack, PackData, PackInflater, iter_sha1, write_pack_header, write_pack_index_v2, write_pack_object, write_pack_objects, compute_file_sha, PackIndexer, PackStreamCopier, ) INFODIR = 'info' PACKDIR = 'pack' class BaseObjectStore(object): """Object store interface.""" def determine_wants_all(self, refs): return [sha for (ref, sha) in refs.items() if sha not in self and not ref.endswith(b"^{}") and not sha == ZERO_SHA] def iter_shas(self, shas): """Iterate over the objects for the specified shas. :param shas: Iterable object with SHAs :return: Object iterator """ return ObjectStoreIterator(self, shas) def contains_loose(self, sha): """Check if a particular object is present by SHA1 and is loose.""" raise NotImplementedError(self.contains_loose) def contains_packed(self, sha): """Check if a particular object is present by SHA1 and is packed.""" raise NotImplementedError(self.contains_packed) def __contains__(self, sha): """Check if a particular object is present by SHA1. This method makes no distinction between loose and packed objects. """ return self.contains_packed(sha) or self.contains_loose(sha) @property def packs(self): """Iterable of pack objects.""" raise NotImplementedError def get_raw(self, name): """Obtain the raw text for an object. :param name: sha for the object. :return: tuple with numeric type and object contents. """ raise NotImplementedError(self.get_raw) def __getitem__(self, sha): """Obtain an object by SHA1.""" type_num, uncomp = self.get_raw(sha) return ShaFile.from_raw_string(type_num, uncomp, sha=sha) def __iter__(self): """Iterate over the SHAs that are present in this store.""" raise NotImplementedError(self.__iter__) def add_object(self, obj): """Add a single object to this object store. """ raise NotImplementedError(self.add_object) def add_objects(self, objects): """Add a set of objects to this object store. :param objects: Iterable over a list of (object, path) tuples """ raise NotImplementedError(self.add_objects) def tree_changes(self, source, target, want_unchanged=False): """Find the differences between the contents of two trees :param source: SHA1 of the source tree :param target: SHA1 of the target tree :param want_unchanged: Whether unchanged files should be reported :return: Iterator over tuples with (oldpath, newpath), (oldmode, newmode), (oldsha, newsha) """ for change in tree_changes(self, source, target, want_unchanged=want_unchanged): yield ((change.old.path, change.new.path), (change.old.mode, change.new.mode), (change.old.sha, change.new.sha)) def iter_tree_contents(self, tree_id, include_trees=False): """Iterate the contents of a tree and all subtrees. Iteration is depth-first pre-order, as in e.g. os.walk. :param tree_id: SHA1 of the tree. :param include_trees: If True, include tree objects in the iteration. :return: Iterator over TreeEntry namedtuples for all the objects in a tree. """ for entry, _ in walk_trees(self, tree_id, None): if not stat.S_ISDIR(entry.mode) or include_trees: yield entry def find_missing_objects(self, haves, wants, progress=None, get_tagged=None, get_parents=lambda commit: commit.parents): """Find the missing objects required for a set of revisions. :param haves: Iterable over SHAs already in common. :param wants: Iterable over SHAs of objects to fetch. :param progress: Simple progress function that will be called with updated progress strings. :param get_tagged: Function that returns a dict of pointed-to sha -> tag sha for including tags. :param get_parents: Optional function for getting the parents of a commit. :return: Iterator over (sha, path) pairs. """ finder = MissingObjectFinder(self, haves, wants, progress, get_tagged, get_parents=get_parents) return iter(finder.next, None) def find_common_revisions(self, graphwalker): """Find which revisions this store has in common using graphwalker. :param graphwalker: A graphwalker object. :return: List of SHAs that are in common """ haves = [] sha = next(graphwalker) while sha: if sha in self: haves.append(sha) graphwalker.ack(sha) sha = next(graphwalker) return haves def generate_pack_contents(self, have, want, progress=None): """Iterate over the contents of a pack file. :param have: List of SHA1s of objects that should not be sent :param want: List of SHA1s of objects that should be sent :param progress: Optional progress reporting method """ return self.iter_shas(self.find_missing_objects(have, want, progress)) def peel_sha(self, sha): """Peel all tags from a SHA. :param sha: The object SHA to peel. :return: The fully-peeled SHA1 of a tag object, after peeling all intermediate tags; if the original ref does not point to a tag, this will equal the original SHA1. """ obj = self[sha] obj_class = object_class(obj.type_name) while obj_class is Tag: obj_class, sha = obj.object obj = self[sha] return obj def _collect_ancestors(self, heads, common=set(), get_parents=lambda commit: commit.parents): """Collect all ancestors of heads up to (excluding) those in common. :param heads: commits to start from :param common: commits to end at, or empty set to walk repository completely :param get_parents: Optional function for getting the parents of a commit. :return: a tuple (A, B) where A - all commits reachable from heads but not present in common, B - common (shared) elements that are directly reachable from heads """ bases = set() commits = set() queue = [] queue.extend(heads) while queue: e = queue.pop(0) if e in common: bases.add(e) elif e not in commits: commits.add(e) cmt = self[e] queue.extend(get_parents(cmt)) return (commits, bases) def close(self): """Close any files opened by this object store.""" # Default implementation is a NO-OP class PackBasedObjectStore(BaseObjectStore): def __init__(self): self._pack_cache = {} @property def alternates(self): return [] def contains_packed(self, sha): """Check if a particular object is present by SHA1 and is packed. This does not check alternates. """ for pack in self.packs: if sha in pack: return True return False def __contains__(self, sha): """Check if a particular object is present by SHA1. This method makes no distinction between loose and packed objects. """ if self.contains_packed(sha) or self.contains_loose(sha): return True for alternate in self.alternates: if sha in alternate: return True return False def _pack_cache_stale(self): """Check whether the pack cache is stale.""" raise NotImplementedError(self._pack_cache_stale) def _add_known_pack(self, base_name, pack): """Add a newly appeared pack to the cache by path. """ self._pack_cache[base_name] = pack def close(self): pack_cache = self._pack_cache self._pack_cache = {} while pack_cache: (name, pack) = pack_cache.popitem() pack.close() @property def packs(self): """List with pack objects.""" if self._pack_cache is None or self._pack_cache_stale(): self._update_pack_cache() return self._pack_cache.values() def _iter_alternate_objects(self): """Iterate over the SHAs of all the objects in alternate stores.""" for alternate in self.alternates: for alternate_object in alternate: yield alternate_object def _iter_loose_objects(self): """Iterate over the SHAs of all loose objects.""" raise NotImplementedError(self._iter_loose_objects) def _get_loose_object(self, sha): raise NotImplementedError(self._get_loose_object) def _remove_loose_object(self, sha): raise NotImplementedError(self._remove_loose_object) def _remove_pack(self, name): raise NotImplementedError(self._remove_pack) def pack_loose_objects(self): """Pack loose objects. :return: Number of objects packed """ objects = set() for sha in self._iter_loose_objects(): objects.add((self._get_loose_object(sha), None)) self.add_objects(list(objects)) for obj, path in objects: self._remove_loose_object(obj.id) return len(objects) def repack(self): """Repack the packs in this repository. Note that this implementation is fairly naive and currently keeps all objects in memory while it repacks. """ loose_objects = set() for sha in self._iter_loose_objects(): loose_objects.add(self._get_loose_object(sha)) objects = {(obj, None) for obj in loose_objects} old_packs = list(self.packs) for pack in old_packs: objects.update((obj, None) for obj in pack.iterobjects()) self.add_objects(objects) for obj in loose_objects: self._remove_loose_object(obj.id) for pack in old_packs: self._remove_pack(pack) + self._update_pack_cache() return len(objects) def __iter__(self): """Iterate over the SHAs that are present in this store.""" iterables = (list(self.packs) + [self._iter_loose_objects()] + [self._iter_alternate_objects()]) return chain(*iterables) def contains_loose(self, sha): """Check if a particular object is present by SHA1 and is loose. This does not check alternates. """ return self._get_loose_object(sha) is not None def get_raw(self, name): """Obtain the raw text for an object. :param name: sha for the object. :return: tuple with numeric type and object contents. """ if len(name) == 40: sha = hex_to_sha(name) hexsha = name elif len(name) == 20: sha = name hexsha = None else: raise AssertionError("Invalid object name %r" % name) for pack in self.packs: try: return pack.get_raw(sha) except KeyError: pass if hexsha is None: hexsha = sha_to_hex(name) ret = self._get_loose_object(hexsha) if ret is not None: return ret.type_num, ret.as_raw_string() for alternate in self.alternates: try: return alternate.get_raw(hexsha) except KeyError: pass raise KeyError(hexsha) def add_objects(self, objects): """Add a set of objects to this object store. :param objects: Iterable over (object, path) tuples, should support __len__. :return: Pack object of the objects written. """ if len(objects) == 0: # Don't bother writing an empty pack file return f, commit, abort = self.add_pack() try: write_pack_objects(f, objects) except: abort() raise else: return commit() class DiskObjectStore(PackBasedObjectStore): """Git-style object store that exists on disk.""" def __init__(self, path): """Open an object store. :param path: Path of the object store. """ super(DiskObjectStore, self).__init__() self.path = path self.pack_dir = os.path.join(self.path, PACKDIR) self._pack_cache_time = 0 self._pack_cache = {} self._alternates = None def __repr__(self): return "<%s(%r)>" % (self.__class__.__name__, self.path) @property def alternates(self): if self._alternates is not None: return self._alternates self._alternates = [] for path in self._read_alternate_paths(): self._alternates.append(DiskObjectStore(path)) return self._alternates def _read_alternate_paths(self): try: f = GitFile(os.path.join(self.path, INFODIR, "alternates"), 'rb') except (OSError, IOError) as e: if e.errno == errno.ENOENT: return raise with f: for l in f.readlines(): l = l.rstrip(b"\n") if l[0] == b"#": continue if os.path.isabs(l): yield l.decode(sys.getfilesystemencoding()) else: yield os.path.join(self.path, l).decode( sys.getfilesystemencoding()) def add_alternate_path(self, path): """Add an alternate path to this object store. """ try: os.mkdir(os.path.join(self.path, INFODIR)) except OSError as e: if e.errno != errno.EEXIST: raise alternates_path = os.path.join(self.path, INFODIR, "alternates") with GitFile(alternates_path, 'wb') as f: try: orig_f = open(alternates_path, 'rb') except (OSError, IOError) as e: if e.errno != errno.ENOENT: raise else: with orig_f: f.write(orig_f.read()) f.write(path.encode(sys.getfilesystemencoding()) + b"\n") if not os.path.isabs(path): path = os.path.join(self.path, path) self.alternates.append(DiskObjectStore(path)) def _update_pack_cache(self): try: pack_dir_contents = os.listdir(self.pack_dir) except OSError as e: if e.errno == errno.ENOENT: self._pack_cache_time = 0 self.close() return raise self._pack_cache_time = max( os.stat(self.pack_dir).st_mtime, time.time()) pack_files = set() for name in pack_dir_contents: if name.startswith("pack-") and name.endswith(".pack"): # verify that idx exists first (otherwise the pack was not yet # fully written) idx_name = os.path.splitext(name)[0] + ".idx" if idx_name in pack_dir_contents: pack_name = name[:-len(".pack")] pack_files.add(pack_name) # Open newly appeared pack files for f in pack_files: if f not in self._pack_cache: self._pack_cache[f] = Pack(os.path.join(self.pack_dir, f)) # Remove disappeared pack files for f in set(self._pack_cache) - pack_files: self._pack_cache.pop(f).close() def _pack_cache_stale(self): try: return os.stat(self.pack_dir).st_mtime >= self._pack_cache_time except OSError as e: if e.errno == errno.ENOENT: return True raise def _get_shafile_path(self, sha): # Check from object dir return hex_to_filename(self.path, sha) def _iter_loose_objects(self): for base in os.listdir(self.path): if len(base) != 2: continue for rest in os.listdir(os.path.join(self.path, base)): yield (base+rest).encode(sys.getfilesystemencoding()) def _get_loose_object(self, sha): path = self._get_shafile_path(sha) try: return ShaFile.from_path(path) except (OSError, IOError) as e: if e.errno == errno.ENOENT: return None raise def _remove_loose_object(self, sha): os.remove(self._get_shafile_path(sha)) def _remove_pack(self, pack): os.remove(pack.data.path) os.remove(pack.index.path) def _get_pack_basepath(self, entries): suffix = iter_sha1(entry[0] for entry in entries) # TODO: Handle self.pack_dir being bytes suffix = suffix.decode('ascii') return os.path.join(self.pack_dir, "pack-" + suffix) def _complete_thin_pack(self, f, path, copier, indexer): """Move a specific file containing a pack into the pack directory. :note: The file should be on the same file system as the packs directory. :param f: Open file object for the pack. :param path: Path to the pack file. :param copier: A PackStreamCopier to use for writing pack data. :param indexer: A PackIndexer for indexing the pack. """ entries = list(indexer) # Update the header with the new number of objects. f.seek(0) write_pack_header(f, len(entries) + len(indexer.ext_refs())) # Must flush before reading (http://bugs.python.org/issue3207) f.flush() # Rescan the rest of the pack, computing the SHA with the new header. new_sha = compute_file_sha(f, end_ofs=-20) # Must reposition before writing (http://bugs.python.org/issue3207) f.seek(0, os.SEEK_CUR) # Complete the pack. for ext_sha in indexer.ext_refs(): assert len(ext_sha) == 20 type_num, data = self.get_raw(ext_sha) offset = f.tell() crc32 = write_pack_object(f, type_num, data, sha=new_sha) entries.append((ext_sha, offset, crc32)) pack_sha = new_sha.digest() f.write(pack_sha) f.close() # Move the pack in. entries.sort() pack_base_name = self._get_pack_basepath(entries) if sys.platform == 'win32': try: os.rename(path, pack_base_name + '.pack') except WindowsError: os.remove(pack_base_name + '.pack') os.rename(path, pack_base_name + '.pack') else: os.rename(path, pack_base_name + '.pack') # Write the index. index_file = GitFile(pack_base_name + '.idx', 'wb') try: write_pack_index_v2(index_file, entries, pack_sha) index_file.close() finally: index_file.abort() # Add the pack to the store and return it. final_pack = Pack(pack_base_name) final_pack.check_length_and_checksum() self._add_known_pack(pack_base_name, final_pack) return final_pack def add_thin_pack(self, read_all, read_some): """Add a new thin pack to this object store. Thin packs are packs that contain deltas with parents that exist outside the pack. They should never be placed in the object store directly, and always indexed and completed as they are copied. :param read_all: Read function that blocks until the number of requested bytes are read. :param read_some: Read function that returns at least one byte, but may not return the number of bytes requested. :return: A Pack object pointing at the now-completed thin pack in the objects/pack directory. """ fd, path = tempfile.mkstemp(dir=self.path, prefix='tmp_pack_') with os.fdopen(fd, 'w+b') as f: indexer = PackIndexer(f, resolve_ext_ref=self.get_raw) copier = PackStreamCopier(read_all, read_some, f, delta_iter=indexer) copier.verify() return self._complete_thin_pack(f, path, copier, indexer) def move_in_pack(self, path): """Move a specific file containing a pack into the pack directory. :note: The file should be on the same file system as the packs directory. :param path: Path to the pack file. """ with PackData(path) as p: entries = p.sorted_entries() basename = self._get_pack_basepath(entries) with GitFile(basename+".idx", "wb") as f: write_pack_index_v2(f, entries, p.get_stored_checksum()) os.rename(path, basename + ".pack") final_pack = Pack(basename) self._add_known_pack(basename, final_pack) return final_pack def add_pack(self): """Add a new pack to this object store. :return: Fileobject to write to, a commit function to call when the pack is finished and an abort function. """ fd, path = tempfile.mkstemp(dir=self.pack_dir, suffix=".pack") f = os.fdopen(fd, 'wb') def commit(): os.fsync(fd) f.close() if os.path.getsize(path) > 0: return self.move_in_pack(path) else: os.remove(path) return None def abort(): f.close() os.remove(path) return f, commit, abort def add_object(self, obj): """Add a single object to this object store. :param obj: Object to add """ path = self._get_shafile_path(obj.id) dir = os.path.dirname(path) try: os.mkdir(dir) except OSError as e: if e.errno != errno.EEXIST: raise if os.path.exists(path): return # Already there, no need to write again with GitFile(path, 'wb') as f: f.write(obj.as_legacy_object()) @classmethod def init(cls, path): try: os.mkdir(path) except OSError as e: if e.errno != errno.EEXIST: raise os.mkdir(os.path.join(path, "info")) os.mkdir(os.path.join(path, PACKDIR)) return cls(path) class MemoryObjectStore(BaseObjectStore): """Object store that keeps all objects in memory.""" def __init__(self): super(MemoryObjectStore, self).__init__() self._data = {} def _to_hexsha(self, sha): if len(sha) == 40: return sha elif len(sha) == 20: return sha_to_hex(sha) else: raise ValueError("Invalid sha %r" % (sha,)) def contains_loose(self, sha): """Check if a particular object is present by SHA1 and is loose.""" return self._to_hexsha(sha) in self._data def contains_packed(self, sha): """Check if a particular object is present by SHA1 and is packed.""" return False def __iter__(self): """Iterate over the SHAs that are present in this store.""" return iter(self._data.keys()) @property def packs(self): """List with pack objects.""" return [] def get_raw(self, name): """Obtain the raw text for an object. :param name: sha for the object. :return: tuple with numeric type and object contents. """ obj = self[self._to_hexsha(name)] return obj.type_num, obj.as_raw_string() def __getitem__(self, name): return self._data[self._to_hexsha(name)].copy() def __delitem__(self, name): """Delete an object from this store, for testing only.""" del self._data[self._to_hexsha(name)] def add_object(self, obj): """Add a single object to this object store. """ self._data[obj.id] = obj.copy() def add_objects(self, objects): """Add a set of objects to this object store. :param objects: Iterable over a list of (object, path) tuples """ for obj, path in objects: self.add_object(obj) def add_pack(self): """Add a new pack to this object store. Because this object store doesn't support packs, we extract and add the individual objects. :return: Fileobject to write to and a commit function to call when the pack is finished. """ f = BytesIO() def commit(): p = PackData.from_file(BytesIO(f.getvalue()), f.tell()) f.close() for obj in PackInflater.for_pack_data(p, self.get_raw): self.add_object(obj) def abort(): pass return f, commit, abort def _complete_thin_pack(self, f, indexer): """Complete a thin pack by adding external references. :param f: Open file object for the pack. :param indexer: A PackIndexer for indexing the pack. """ entries = list(indexer) # Update the header with the new number of objects. f.seek(0) write_pack_header(f, len(entries) + len(indexer.ext_refs())) # Rescan the rest of the pack, computing the SHA with the new header. new_sha = compute_file_sha(f, end_ofs=-20) # Complete the pack. for ext_sha in indexer.ext_refs(): assert len(ext_sha) == 20 type_num, data = self.get_raw(ext_sha) write_pack_object(f, type_num, data, sha=new_sha) pack_sha = new_sha.digest() f.write(pack_sha) def add_thin_pack(self, read_all, read_some): """Add a new thin pack to this object store. Thin packs are packs that contain deltas with parents that exist outside the pack. Because this object store doesn't support packs, we extract and add the individual objects. :param read_all: Read function that blocks until the number of requested bytes are read. :param read_some: Read function that returns at least one byte, but may not return the number of bytes requested. """ f, commit, abort = self.add_pack() try: indexer = PackIndexer(f, resolve_ext_ref=self.get_raw) copier = PackStreamCopier(read_all, read_some, f, delta_iter=indexer) copier.verify() self._complete_thin_pack(f, indexer) except: abort() raise else: commit() class ObjectImporter(object): """Interface for importing objects.""" def __init__(self, count): """Create a new ObjectImporter. :param count: Number of objects that's going to be imported. """ self.count = count def add_object(self, object): """Add an object.""" raise NotImplementedError(self.add_object) def finish(self, object): """Finish the import and write objects to disk.""" raise NotImplementedError(self.finish) class ObjectIterator(object): """Interface for iterating over objects.""" def iterobjects(self): raise NotImplementedError(self.iterobjects) class ObjectStoreIterator(ObjectIterator): """ObjectIterator that works on top of an ObjectStore.""" def __init__(self, store, sha_iter): """Create a new ObjectIterator. :param store: Object store to retrieve from :param sha_iter: Iterator over (sha, path) tuples """ self.store = store self.sha_iter = sha_iter self._shas = [] def __iter__(self): """Yield tuple with next object and path.""" for sha, path in self.itershas(): yield self.store[sha], path def iterobjects(self): """Iterate over just the objects.""" for o, path in self: yield o def itershas(self): """Iterate over the SHAs.""" for sha in self._shas: yield sha for sha in self.sha_iter: self._shas.append(sha) yield sha def __contains__(self, needle): """Check if an object is present. :note: This checks if the object is present in the underlying object store, not if it would be yielded by the iterator. :param needle: SHA1 of the object to check for """ return needle in self.store def __getitem__(self, key): """Find an object by SHA1. :note: This retrieves the object from the underlying object store. It will also succeed if the object would not be returned by the iterator. """ return self.store[key] def __len__(self): """Return the number of objects.""" return len(list(self.itershas())) def tree_lookup_path(lookup_obj, root_sha, path): """Look up an object in a Git tree. :param lookup_obj: Callback for retrieving object by SHA1 :param root_sha: SHA1 of the root tree :param path: Path to lookup :return: A tuple of (mode, SHA) of the resulting path. """ tree = lookup_obj(root_sha) if not isinstance(tree, Tree): raise NotTreeError(root_sha) return tree.lookup_path(lookup_obj, path) def _collect_filetree_revs(obj_store, tree_sha, kset): """Collect SHA1s of files and directories for specified tree. :param obj_store: Object store to get objects by SHA from :param tree_sha: tree reference to walk :param kset: set to fill with references to files and directories """ filetree = obj_store[tree_sha] for name, mode, sha in filetree.iteritems(): if not S_ISGITLINK(mode) and sha not in kset: kset.add(sha) if stat.S_ISDIR(mode): _collect_filetree_revs(obj_store, sha, kset) def _split_commits_and_tags(obj_store, lst, ignore_unknown=False): """Split object id list into three lists with commit, tag, and other SHAs. Commits referenced by tags are included into commits list as well. Only SHA1s known in this repository will get through, and unless ignore_unknown argument is True, KeyError is thrown for SHA1 missing in the repository :param obj_store: Object store to get objects by SHA1 from :param lst: Collection of commit and tag SHAs :param ignore_unknown: True to skip SHA1 missing in the repository silently. :return: A tuple of (commits, tags, others) SHA1s """ commits = set() tags = set() others = set() for e in lst: try: o = obj_store[e] except KeyError: if not ignore_unknown: raise else: if isinstance(o, Commit): commits.add(e) elif isinstance(o, Tag): tags.add(e) tagged = o.object[1] c, t, o = _split_commits_and_tags( obj_store, [tagged], ignore_unknown=ignore_unknown) commits |= c tags |= t others |= o else: others.add(e) return (commits, tags, others) class MissingObjectFinder(object): """Find the objects missing from another object store. :param object_store: Object store containing at least all objects to be sent :param haves: SHA1s of commits not to send (already present in target) :param wants: SHA1s of commits to send :param progress: Optional function to report progress to. :param get_tagged: Function that returns a dict of pointed-to sha -> tag sha for including tags. :param get_parents: Optional function for getting the parents of a commit. :param tagged: dict of pointed-to sha -> tag sha for including tags """ def __init__(self, object_store, haves, wants, progress=None, get_tagged=None, get_parents=lambda commit: commit.parents): self.object_store = object_store self._get_parents = get_parents # process Commits and Tags differently # Note, while haves may list commits/tags not available locally, # and such SHAs would get filtered out by _split_commits_and_tags, # wants shall list only known SHAs, and otherwise # _split_commits_and_tags fails with KeyError have_commits, have_tags, have_others = ( _split_commits_and_tags(object_store, haves, True)) want_commits, want_tags, want_others = ( _split_commits_and_tags(object_store, wants, False)) # all_ancestors is a set of commits that shall not be sent # (complete repository up to 'haves') all_ancestors = object_store._collect_ancestors( have_commits, get_parents=self._get_parents)[0] # all_missing - complete set of commits between haves and wants # common - commits from all_ancestors we hit into while # traversing parent hierarchy of wants missing_commits, common_commits = object_store._collect_ancestors( want_commits, all_ancestors, get_parents=self._get_parents) self.sha_done = set() # Now, fill sha_done with commits and revisions of # files and directories known to be both locally # and on target. Thus these commits and files # won't get selected for fetch for h in common_commits: self.sha_done.add(h) cmt = object_store[h] _collect_filetree_revs(object_store, cmt.tree, self.sha_done) # record tags we have as visited, too for t in have_tags: self.sha_done.add(t) missing_tags = want_tags.difference(have_tags) missing_others = want_others.difference(have_others) # in fact, what we 'want' is commits, tags, and others # we've found missing wants = missing_commits.union(missing_tags) wants = wants.union(missing_others) self.objects_to_send = set([(w, None, False) for w in wants]) if progress is None: self.progress = lambda x: None else: self.progress = progress self._tagged = get_tagged and get_tagged() or {} def add_todo(self, entries): self.objects_to_send.update([e for e in entries if not e[0] in self.sha_done]) def next(self): while True: if not self.objects_to_send: return None (sha, name, leaf) = self.objects_to_send.pop() if sha not in self.sha_done: break if not leaf: o = self.object_store[sha] if isinstance(o, Commit): self.add_todo([(o.tree, "", False)]) elif isinstance(o, Tree): self.add_todo([(s, n, not stat.S_ISDIR(m)) for n, m, s in o.iteritems() if not S_ISGITLINK(m)]) elif isinstance(o, Tag): self.add_todo([(o.object[1], None, False)]) if sha in self._tagged: self.add_todo([(self._tagged[sha], None, True)]) self.sha_done.add(sha) self.progress(("counting objects: %d\r" % len(self.sha_done)).encode('ascii')) return (sha, name) __next__ = next class ObjectStoreGraphWalker(object): """Graph walker that finds what commits are missing from an object store. :ivar heads: Revisions without descendants in the local repo :ivar get_parents: Function to retrieve parents in the local repo """ def __init__(self, local_heads, get_parents): """Create a new instance. :param local_heads: Heads to start search with :param get_parents: Function for finding the parents of a SHA1. """ self.heads = set(local_heads) self.get_parents = get_parents self.parents = {} def ack(self, sha): """Ack that a revision and its ancestors are present in the source.""" if len(sha) != 40: raise ValueError("unexpected sha %r received" % sha) ancestors = set([sha]) # stop if we run out of heads to remove while self.heads: for a in ancestors: if a in self.heads: self.heads.remove(a) # collect all ancestors new_ancestors = set() for a in ancestors: ps = self.parents.get(a) if ps is not None: new_ancestors.update(ps) self.parents[a] = None # no more ancestors; stop if not new_ancestors: break ancestors = new_ancestors def next(self): """Iterate over ancestors of heads in the target.""" if self.heads: ret = self.heads.pop() ps = self.get_parents(ret) self.parents[ret] = ps self.heads.update( [p for p in ps if p not in self.parents]) return ret return None __next__ = next def commit_tree_changes(object_store, tree, changes): """Commit a specified set of changes to a tree structure. This will apply a set of changes on top of an existing tree, storing new objects in object_store. changes are a list of tuples with (path, mode, object_sha). Paths can be both blobs and trees. See the mode and object sha to None deletes the path. This method works especially well if there are only a small number of changes to a big tree. For a large number of changes to a large tree, use e.g. commit_tree. :param object_store: Object store to store new objects in and retrieve old ones from. :param tree: Original tree root :param changes: changes to apply :return: New tree root object """ # TODO(jelmer): Save up the objects and add them using .add_objects # rather than with individual calls to .add_object. nested_changes = {} for (path, new_mode, new_sha) in changes: try: (dirname, subpath) = path.split(b'/', 1) except ValueError: if new_sha is None: del tree[path] else: tree[path] = (new_mode, new_sha) else: nested_changes.setdefault(dirname, []).append( (subpath, new_mode, new_sha)) for name, subchanges in nested_changes.items(): try: orig_subtree = object_store[tree[name][1]] except KeyError: orig_subtree = Tree() subtree = commit_tree_changes(object_store, orig_subtree, subchanges) if len(subtree) == 0: del tree[name] else: tree[name] = (stat.S_IFDIR, subtree.id) object_store.add_object(tree) return tree diff --git a/dulwich/tests/test_object_store.py b/dulwich/tests/test_object_store.py index b42cb3c6..c9df3451 100644 --- a/dulwich/tests/test_object_store.py +++ b/dulwich/tests/test_object_store.py @@ -1,591 +1,591 @@ # test_object_store.py -- tests for object_store.py # Copyright (C) 2008 Jelmer Vernooij # # Dulwich is dual-licensed under the Apache License, Version 2.0 and the GNU # General Public License as public by the Free Software Foundation; version 2.0 # or (at your option) any later version. You can redistribute it and/or # modify it under the terms of either of these two licenses. # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # # You should have received a copy of the licenses; if not, see # for a copy of the GNU General Public License # and for a copy of the Apache # License, Version 2.0. # """Tests for the object store interface.""" from contextlib import closing from io import BytesIO import os import shutil import stat import tempfile from dulwich.index import ( commit_tree, ) from dulwich.errors import ( NotTreeError, ) from dulwich.objects import ( sha_to_hex, Blob, Tree, TreeEntry, ) from dulwich.object_store import ( DiskObjectStore, MemoryObjectStore, ObjectStoreGraphWalker, commit_tree_changes, tree_lookup_path, ) from dulwich.pack import ( REF_DELTA, write_pack_objects, ) from dulwich.tests import ( TestCase, ) from dulwich.tests.utils import ( make_object, make_tag, build_pack, ) testobject = make_object(Blob, data=b"yummy data") class ObjectStoreTests(object): def test_determine_wants_all(self): self.assertEqual( [b"1" * 40], self.store.determine_wants_all({b"refs/heads/foo": b"1" * 40})) def test_determine_wants_all_zero(self): self.assertEqual( [], self.store.determine_wants_all({b"refs/heads/foo": b"0" * 40})) def test_iter(self): self.assertEqual([], list(self.store)) def test_get_nonexistant(self): self.assertRaises(KeyError, lambda: self.store[b"a" * 40]) def test_contains_nonexistant(self): self.assertFalse((b"a" * 40) in self.store) def test_add_objects_empty(self): self.store.add_objects([]) def test_add_commit(self): # TODO: Argh, no way to construct Git commit objects without # access to a serialized form. self.store.add_objects([]) def test_store_resilience(self): """Test if updating an existing stored object doesn't erase the object from the store. """ test_object = make_object(Blob, data=b'data') self.store.add_object(test_object) test_object_id = test_object.id test_object.data = test_object.data + b'update' stored_test_object = self.store[test_object_id] self.assertNotEqual(test_object.id, stored_test_object.id) self.assertEqual(stored_test_object.id, test_object_id) def test_add_object(self): self.store.add_object(testobject) self.assertEqual(set([testobject.id]), set(self.store)) self.assertTrue(testobject.id in self.store) r = self.store[testobject.id] self.assertEqual(r, testobject) def test_add_objects(self): data = [(testobject, "mypath")] self.store.add_objects(data) self.assertEqual(set([testobject.id]), set(self.store)) self.assertTrue(testobject.id in self.store) r = self.store[testobject.id] self.assertEqual(r, testobject) def test_tree_changes(self): blob_a1 = make_object(Blob, data=b'a1') blob_a2 = make_object(Blob, data=b'a2') blob_b = make_object(Blob, data=b'b') for blob in [blob_a1, blob_a2, blob_b]: self.store.add_object(blob) blobs_1 = [(b'a', blob_a1.id, 0o100644), (b'b', blob_b.id, 0o100644)] tree1_id = commit_tree(self.store, blobs_1) blobs_2 = [(b'a', blob_a2.id, 0o100644), (b'b', blob_b.id, 0o100644)] tree2_id = commit_tree(self.store, blobs_2) change_a = ((b'a', b'a'), (0o100644, 0o100644), (blob_a1.id, blob_a2.id)) self.assertEqual([change_a], list(self.store.tree_changes(tree1_id, tree2_id))) self.assertEqual( [change_a, ((b'b', b'b'), (0o100644, 0o100644), (blob_b.id, blob_b.id))], list(self.store.tree_changes(tree1_id, tree2_id, want_unchanged=True))) def test_iter_tree_contents(self): blob_a = make_object(Blob, data=b'a') blob_b = make_object(Blob, data=b'b') blob_c = make_object(Blob, data=b'c') for blob in [blob_a, blob_b, blob_c]: self.store.add_object(blob) blobs = [ (b'a', blob_a.id, 0o100644), (b'ad/b', blob_b.id, 0o100644), (b'ad/bd/c', blob_c.id, 0o100755), (b'ad/c', blob_c.id, 0o100644), (b'c', blob_c.id, 0o100644), ] tree_id = commit_tree(self.store, blobs) self.assertEqual([TreeEntry(p, m, h) for (p, h, m) in blobs], list(self.store.iter_tree_contents(tree_id))) def test_iter_tree_contents_include_trees(self): blob_a = make_object(Blob, data=b'a') blob_b = make_object(Blob, data=b'b') blob_c = make_object(Blob, data=b'c') for blob in [blob_a, blob_b, blob_c]: self.store.add_object(blob) blobs = [ (b'a', blob_a.id, 0o100644), (b'ad/b', blob_b.id, 0o100644), (b'ad/bd/c', blob_c.id, 0o100755), ] tree_id = commit_tree(self.store, blobs) tree = self.store[tree_id] tree_ad = self.store[tree[b'ad'][1]] tree_bd = self.store[tree_ad[b'bd'][1]] expected = [ TreeEntry(b'', 0o040000, tree_id), TreeEntry(b'a', 0o100644, blob_a.id), TreeEntry(b'ad', 0o040000, tree_ad.id), TreeEntry(b'ad/b', 0o100644, blob_b.id), TreeEntry(b'ad/bd', 0o040000, tree_bd.id), TreeEntry(b'ad/bd/c', 0o100755, blob_c.id), ] actual = self.store.iter_tree_contents(tree_id, include_trees=True) self.assertEqual(expected, list(actual)) def make_tag(self, name, obj): tag = make_tag(obj, name=name) self.store.add_object(tag) return tag def test_peel_sha(self): self.store.add_object(testobject) tag1 = self.make_tag(b'1', testobject) tag2 = self.make_tag(b'2', testobject) tag3 = self.make_tag(b'3', testobject) for obj in [testobject, tag1, tag2, tag3]: self.assertEqual(testobject, self.store.peel_sha(obj.id)) def test_get_raw(self): self.store.add_object(testobject) self.assertEqual((Blob.type_num, b'yummy data'), self.store.get_raw(testobject.id)) def test_close(self): # For now, just check that close doesn't barf. self.store.add_object(testobject) self.store.close() class MemoryObjectStoreTests(ObjectStoreTests, TestCase): def setUp(self): TestCase.setUp(self) self.store = MemoryObjectStore() def test_add_pack(self): o = MemoryObjectStore() f, commit, abort = o.add_pack() try: b = make_object(Blob, data=b"more yummy data") write_pack_objects(f, [(b, None)]) except: abort() raise else: commit() def test_add_pack_emtpy(self): o = MemoryObjectStore() f, commit, abort = o.add_pack() commit() def test_add_thin_pack(self): o = MemoryObjectStore() blob = make_object(Blob, data=b'yummy data') o.add_object(blob) f = BytesIO() entries = build_pack(f, [ (REF_DELTA, (blob.id, b'more yummy data')), ], store=o) o.add_thin_pack(f.read, None) packed_blob_sha = sha_to_hex(entries[0][3]) self.assertEqual((Blob.type_num, b'more yummy data'), o.get_raw(packed_blob_sha)) def test_add_thin_pack_empty(self): o = MemoryObjectStore() f = BytesIO() entries = build_pack(f, [], store=o) self.assertEqual([], entries) o.add_thin_pack(f.read, None) class PackBasedObjectStoreTests(ObjectStoreTests): def tearDown(self): for pack in self.store.packs: pack.close() def test_empty_packs(self): self.assertEqual([], list(self.store.packs)) def test_pack_loose_objects(self): b1 = make_object(Blob, data=b"yummy data") self.store.add_object(b1) b2 = make_object(Blob, data=b"more yummy data") self.store.add_object(b2) b3 = make_object(Blob, data=b"even more yummy data") b4 = make_object(Blob, data=b"and more yummy data") self.store.add_objects([(b3, None), (b4, None)]) self.assertEqual({b1.id, b2.id, b3.id, b4.id}, set(self.store)) self.assertEqual(1, len(self.store.packs)) self.assertEqual(2, self.store.pack_loose_objects()) self.assertNotEqual([], list(self.store.packs)) self.assertEqual(0, self.store.pack_loose_objects()) def test_repack(self): b1 = make_object(Blob, data=b"yummy data") self.store.add_object(b1) b2 = make_object(Blob, data=b"more yummy data") self.store.add_object(b2) b3 = make_object(Blob, data=b"even more yummy data") b4 = make_object(Blob, data=b"and more yummy data") self.store.add_objects([(b3, None), (b4, None)]) b5 = make_object(Blob, data=b"and more data") b6 = make_object(Blob, data=b"and some more data") self.store.add_objects([(b5, None), (b6, None)]) self.assertEqual({b1.id, b2.id, b3.id, b4.id, b5.id, b6.id}, set(self.store)) self.assertEqual(2, len(self.store.packs)) self.assertEqual(6, self.store.repack()) - self.assertNotEqual(1, len(self.store.packs)) + self.assertEqual(1, len(self.store.packs)) self.assertEqual(0, self.store.pack_loose_objects()) class DiskObjectStoreTests(PackBasedObjectStoreTests, TestCase): def setUp(self): TestCase.setUp(self) self.store_dir = tempfile.mkdtemp() self.addCleanup(shutil.rmtree, self.store_dir) self.store = DiskObjectStore.init(self.store_dir) def tearDown(self): TestCase.tearDown(self) PackBasedObjectStoreTests.tearDown(self) def test_alternates(self): alternate_dir = tempfile.mkdtemp() self.addCleanup(shutil.rmtree, alternate_dir) alternate_store = DiskObjectStore(alternate_dir) b2 = make_object(Blob, data=b"yummy data") alternate_store.add_object(b2) store = DiskObjectStore(self.store_dir) self.assertRaises(KeyError, store.__getitem__, b2.id) store.add_alternate_path(alternate_dir) self.assertIn(b2.id, store) self.assertEqual(b2, store[b2.id]) def test_add_alternate_path(self): store = DiskObjectStore(self.store_dir) self.assertEqual([], list(store._read_alternate_paths())) store.add_alternate_path("/foo/path") self.assertEqual(["/foo/path"], list(store._read_alternate_paths())) store.add_alternate_path("/bar/path") self.assertEqual( ["/foo/path", "/bar/path"], list(store._read_alternate_paths())) def test_rel_alternative_path(self): alternate_dir = tempfile.mkdtemp() self.addCleanup(shutil.rmtree, alternate_dir) alternate_store = DiskObjectStore(alternate_dir) b2 = make_object(Blob, data=b"yummy data") alternate_store.add_object(b2) store = DiskObjectStore(self.store_dir) self.assertRaises(KeyError, store.__getitem__, b2.id) store.add_alternate_path( os.path.relpath(alternate_dir, self.store_dir)) self.assertEqual(list(alternate_store), list(store.alternates[0])) self.assertIn(b2.id, store) self.assertEqual(b2, store[b2.id]) def test_pack_dir(self): o = DiskObjectStore(self.store_dir) self.assertEqual(os.path.join(self.store_dir, "pack"), o.pack_dir) def test_add_pack(self): o = DiskObjectStore(self.store_dir) f, commit, abort = o.add_pack() try: b = make_object(Blob, data=b"more yummy data") write_pack_objects(f, [(b, None)]) except: abort() raise else: commit() def test_add_thin_pack(self): o = DiskObjectStore(self.store_dir) try: blob = make_object(Blob, data=b'yummy data') o.add_object(blob) f = BytesIO() entries = build_pack(f, [ (REF_DELTA, (blob.id, b'more yummy data')), ], store=o) with o.add_thin_pack(f.read, None) as pack: packed_blob_sha = sha_to_hex(entries[0][3]) pack.check_length_and_checksum() self.assertEqual( sorted([blob.id, packed_blob_sha]), list(pack)) self.assertTrue(o.contains_packed(packed_blob_sha)) self.assertTrue(o.contains_packed(blob.id)) self.assertEqual((Blob.type_num, b'more yummy data'), o.get_raw(packed_blob_sha)) finally: o.close() def test_add_thin_pack_empty(self): with closing(DiskObjectStore(self.store_dir)) as o: f = BytesIO() entries = build_pack(f, [], store=o) self.assertEqual([], entries) o.add_thin_pack(f.read, None) class TreeLookupPathTests(TestCase): def setUp(self): TestCase.setUp(self) self.store = MemoryObjectStore() blob_a = make_object(Blob, data=b'a') blob_b = make_object(Blob, data=b'b') blob_c = make_object(Blob, data=b'c') for blob in [blob_a, blob_b, blob_c]: self.store.add_object(blob) blobs = [ (b'a', blob_a.id, 0o100644), (b'ad/b', blob_b.id, 0o100644), (b'ad/bd/c', blob_c.id, 0o100755), (b'ad/c', blob_c.id, 0o100644), (b'c', blob_c.id, 0o100644), ] self.tree_id = commit_tree(self.store, blobs) def get_object(self, sha): return self.store[sha] def test_lookup_blob(self): o_id = tree_lookup_path(self.get_object, self.tree_id, b'a')[1] self.assertTrue(isinstance(self.store[o_id], Blob)) def test_lookup_tree(self): o_id = tree_lookup_path(self.get_object, self.tree_id, b'ad')[1] self.assertTrue(isinstance(self.store[o_id], Tree)) o_id = tree_lookup_path(self.get_object, self.tree_id, b'ad/bd')[1] self.assertTrue(isinstance(self.store[o_id], Tree)) o_id = tree_lookup_path(self.get_object, self.tree_id, b'ad/bd/')[1] self.assertTrue(isinstance(self.store[o_id], Tree)) def test_lookup_nonexistent(self): self.assertRaises( KeyError, tree_lookup_path, self.get_object, self.tree_id, b'j') def test_lookup_not_tree(self): self.assertRaises( NotTreeError, tree_lookup_path, self.get_object, self.tree_id, b'ad/b/j') class ObjectStoreGraphWalkerTests(TestCase): def get_walker(self, heads, parent_map): new_parent_map = dict( [(k * 40, [(p * 40) for p in ps]) for (k, ps) in parent_map.items()]) return ObjectStoreGraphWalker([x * 40 for x in heads], new_parent_map.__getitem__) def test_ack_invalid_value(self): gw = self.get_walker([], {}) self.assertRaises(ValueError, gw.ack, "tooshort") def test_empty(self): gw = self.get_walker([], {}) self.assertIs(None, next(gw)) gw.ack(b"a" * 40) self.assertIs(None, next(gw)) def test_descends(self): gw = self.get_walker([b"a"], {b"a": [b"b"], b"b": []}) self.assertEqual(b"a" * 40, next(gw)) self.assertEqual(b"b" * 40, next(gw)) def test_present(self): gw = self.get_walker([b"a"], {b"a": [b"b"], b"b": []}) gw.ack(b"a" * 40) self.assertIs(None, next(gw)) def test_parent_present(self): gw = self.get_walker([b"a"], {b"a": [b"b"], b"b": []}) self.assertEqual(b"a" * 40, next(gw)) gw.ack(b"a" * 40) self.assertIs(None, next(gw)) def test_child_ack_later(self): gw = self.get_walker([b"a"], {b"a": [b"b"], b"b": [b"c"], b"c": []}) self.assertEqual(b"a" * 40, next(gw)) self.assertEqual(b"b" * 40, next(gw)) gw.ack(b"a" * 40) self.assertIs(None, next(gw)) def test_only_once(self): # a b # | | # c d # \ / # e gw = self.get_walker([b"a", b"b"], { b"a": [b"c"], b"b": [b"d"], b"c": [b"e"], b"d": [b"e"], b"e": [], }) walk = [] acked = False walk.append(next(gw)) walk.append(next(gw)) # A branch (a, c) or (b, d) may be done after 2 steps or 3 depending on # the order walked: 3-step walks include (a, b, c) and (b, a, d), etc. if walk == [b"a" * 40, b"c" * 40] or walk == [b"b" * 40, b"d" * 40]: gw.ack(walk[0]) acked = True walk.append(next(gw)) if not acked and walk[2] == b"c" * 40: gw.ack(b"a" * 40) elif not acked and walk[2] == b"d" * 40: gw.ack(b"b" * 40) walk.append(next(gw)) self.assertIs(None, next(gw)) self.assertEqual([b"a" * 40, b"b" * 40, b"c" * 40, b"d" * 40], sorted(walk)) self.assertLess(walk.index(b"a" * 40), walk.index(b"c" * 40)) self.assertLess(walk.index(b"b" * 40), walk.index(b"d" * 40)) class CommitTreeChangesTests(TestCase): def setUp(self): super(CommitTreeChangesTests, self).setUp() self.store = MemoryObjectStore() self.blob_a = make_object(Blob, data=b'a') self.blob_b = make_object(Blob, data=b'b') self.blob_c = make_object(Blob, data=b'c') for blob in [self.blob_a, self.blob_b, self.blob_c]: self.store.add_object(blob) blobs = [ (b'a', self.blob_a.id, 0o100644), (b'ad/b', self.blob_b.id, 0o100644), (b'ad/bd/c', self.blob_c.id, 0o100755), (b'ad/c', self.blob_c.id, 0o100644), (b'c', self.blob_c.id, 0o100644), ] self.tree_id = commit_tree(self.store, blobs) def test_no_changes(self): self.assertEqual( self.store[self.tree_id], commit_tree_changes(self.store, self.store[self.tree_id], [])) def test_add_blob(self): blob_d = make_object(Blob, data=b'd') new_tree = commit_tree_changes( self.store, self.store[self.tree_id], [ (b'd', 0o100644, blob_d.id)]) self.assertEqual( new_tree[b'd'], (33188, b'c59d9b6344f1af00e504ba698129f07a34bbed8d')) def test_add_blob_in_dir(self): blob_d = make_object(Blob, data=b'd') new_tree = commit_tree_changes( self.store, self.store[self.tree_id], [ (b'e/f/d', 0o100644, blob_d.id)]) self.assertEqual( new_tree.items(), [ TreeEntry(path=b'a', mode=stat.S_IFREG | 0o100644, sha=self.blob_a.id), TreeEntry(path=b'ad', mode=stat.S_IFDIR, sha=b'0e2ce2cd7725ff4817791be31ccd6e627e801f4a'), TreeEntry(path=b'c', mode=stat.S_IFREG | 0o100644, sha=self.blob_c.id), TreeEntry(path=b'e', mode=stat.S_IFDIR, sha=b'6ab344e288724ac2fb38704728b8896e367ed108') ]) e_tree = self.store[new_tree[b'e'][1]] self.assertEqual( e_tree.items(), [ TreeEntry(path=b'f', mode=stat.S_IFDIR, sha=b'24d2c94d8af232b15a0978c006bf61ef4479a0a5') ]) f_tree = self.store[e_tree[b'f'][1]] self.assertEqual( f_tree.items(), [ TreeEntry(path=b'd', mode=stat.S_IFREG | 0o100644, sha=blob_d.id) ]) def test_delete_blob(self): new_tree = commit_tree_changes( self.store, self.store[self.tree_id], [ (b'ad/bd/c', None, None)]) self.assertEqual(set(new_tree), {b'a', b'ad', b'c'}) ad_tree = self.store[new_tree[b'ad'][1]] self.assertEqual(set(ad_tree), {b'b', b'c'})