| OLD | NEW |
| (Empty) |
| 1 # Copyright 2014 The Chromium Authors. All rights reserved. | |
| 2 # Use of this source code is governed by a BSD-style license that can be | |
| 3 # found in the LICENSE file. | |
| 4 import collections | |
| 5 import fnmatch | |
| 6 import logging | |
| 7 import os | |
| 8 import subprocess | |
| 9 import sys | |
| 10 import tempfile | |
| 11 import urlparse | |
| 12 | |
| 13 from infra.services.gnumbd.support.util import ( | |
| 14 cached_property, CalledProcessError) | |
| 15 | |
| 16 from infra.services.gnumbd.support.data import CommitData | |
| 17 | |
| 18 LOGGER = logging.getLogger(__name__) | |
| 19 | |
| 20 | |
| 21 class _Invalid(object): | |
| 22 def __call__(self, *_args, **_kwargs): | |
| 23 return self | |
| 24 | |
| 25 def __getattr__(self, _key): | |
| 26 return self | |
| 27 | |
| 28 def __eq__(self, _other): | |
| 29 return False | |
| 30 | |
| 31 def __ne__(self, _other): # pylint: disable=R0201 | |
| 32 return True | |
| 33 | |
| 34 INVALID = _Invalid() | |
| 35 | |
| 36 | |
| 37 class Repo(object): | |
| 38 """Represents a remote git repo. | |
| 39 | |
| 40 Manages the (bare) on-disk mirror of the remote repo. | |
| 41 """ | |
| 42 MAX_CACHE_SIZE = 1024 | |
| 43 | |
| 44 def __init__(self, url): | |
| 45 self.dry_run = False | |
| 46 self.repos_dir = None | |
| 47 | |
| 48 self._url = url | |
| 49 self._repo_path = None | |
| 50 self._commit_cache = collections.OrderedDict() | |
| 51 self._log = LOGGER.getChild('Repo') | |
| 52 | |
| 53 def reify(self): | |
| 54 """Ensures the local mirror of this Repo exists.""" | |
| 55 assert self.repos_dir is not None | |
| 56 parsed = urlparse.urlparse(self._url) | |
| 57 norm_url = parsed.netloc + parsed.path | |
| 58 if norm_url.endswith('.git'): | |
| 59 norm_url = norm_url[:-len('.git')] | |
| 60 folder = norm_url.replace('-', '--').replace('/', '-').lower() | |
| 61 | |
| 62 rpath = os.path.abspath(os.path.join(self.repos_dir, folder)) | |
| 63 if not os.path.isdir(rpath): | |
| 64 self._log.debug('initializing %r -> %r', self, rpath) | |
| 65 name = tempfile.mkdtemp(dir=self.repos_dir) | |
| 66 self.run('clone', '--mirror', self._url, os.path.basename(name), | |
| 67 stdout=sys.stdout, stderr=sys.stderr, cwd=self.repos_dir) | |
| 68 os.rename(os.path.join(self.repos_dir, name), | |
| 69 os.path.join(self.repos_dir, folder)) | |
| 70 else: | |
| 71 self._log.debug('%r already initialized', self) | |
| 72 self._repo_path = rpath | |
| 73 | |
| 74 # This causes pushes to fail, so unset it. | |
| 75 self.run('config', '--unset', 'remote.origin.mirror', ok_ret={0, 5}) | |
| 76 | |
| 77 # Representation | |
| 78 def __repr__(self): | |
| 79 return 'Repo({_url!r})'.format(**self.__dict__) | |
| 80 | |
| 81 # Methods | |
| 82 def get_commit(self, hsh): | |
| 83 """Creates a new |Commit| object for this |Repo|. | |
| 84 | |
| 85 Uses a very basic LRU cache for commit objects, keeping up to | |
| 86 |MAX_CACHE_SIZE| before eviction. This cuts down on the number of redundant | |
| 87 git commands by > 50%, and allows expensive cached_property's to remain | |
| 88 for the life of the process. | |
| 89 """ | |
| 90 if hsh in self._commit_cache: | |
| 91 self._log.debug('Hit %s', hsh) | |
| 92 r = self._commit_cache.pop(hsh) | |
| 93 else: | |
| 94 self._log.debug('Miss %s', hsh) | |
| 95 if len(self._commit_cache) >= self.MAX_CACHE_SIZE: | |
| 96 self._commit_cache.popitem(last=False) | |
| 97 r = Commit(self, hsh) | |
| 98 | |
| 99 self._commit_cache[hsh] = r | |
| 100 return r | |
| 101 | |
| 102 def refglob(self, globstring): | |
| 103 """Yield every Ref in this repo which matches |globstring|.""" | |
| 104 for _, ref in (l.split() for l in self.run('show-ref').splitlines()): | |
| 105 if fnmatch.fnmatch(ref, globstring): | |
| 106 yield Ref(self, ref) | |
| 107 | |
| 108 def run(self, *args, **kwargs): | |
| 109 """Yet-another-git-subprocess-wrapper. | |
| 110 | |
| 111 Args: argv tokens. 'git' is always argv[0] | |
| 112 | |
| 113 Kwargs: | |
| 114 indata - String data to feed to communicate() | |
| 115 ok_ret - A set() of valid return codes. Defaults to {0}. | |
| 116 ... - passes through to subprocess.Popen() | |
| 117 """ | |
| 118 if args[0] == 'push' and self.dry_run: | |
| 119 self._log.warn('DRY-RUN: Would have pushed %r', args[1:]) | |
| 120 return | |
| 121 | |
| 122 if not 'cwd' in kwargs: | |
| 123 assert self._repo_path is not None | |
| 124 kwargs.setdefault('cwd', self._repo_path) | |
| 125 | |
| 126 kwargs.setdefault('stderr', subprocess.PIPE) | |
| 127 kwargs.setdefault('stdout', subprocess.PIPE) | |
| 128 indata = kwargs.pop('indata', None) | |
| 129 if indata: | |
| 130 assert 'stdin' not in kwargs | |
| 131 kwargs['stdin'] = subprocess.PIPE | |
| 132 ok_ret = kwargs.pop('ok_ret', {0}) | |
| 133 cmd = ('git',) + args | |
| 134 | |
| 135 self._log.debug('Running %r', cmd) | |
| 136 process = subprocess.Popen(cmd, **kwargs) | |
| 137 output, errout = process.communicate(indata) | |
| 138 retcode = process.poll() | |
| 139 if retcode not in ok_ret: | |
| 140 raise CalledProcessError(retcode, cmd, output, errout) | |
| 141 | |
| 142 if errout: | |
| 143 sys.stderr.write(errout) | |
| 144 return output | |
| 145 | |
| 146 def intern(self, data, typ='blob'): | |
| 147 return self.run( | |
| 148 'hash-object', '-w', '-t', typ, '--stdin', indata=str(data)).strip() | |
| 149 | |
| 150 | |
| 151 class Commit(object): | |
| 152 """Represents the identity of a commit in a git repo.""" | |
| 153 | |
| 154 def __init__(self, repo, hsh): | |
| 155 """ | |
| 156 @type repo: Repo | |
| 157 """ | |
| 158 assert CommitData.HASH_RE.match(hsh) | |
| 159 self._repo = repo | |
| 160 self._hsh = hsh | |
| 161 | |
| 162 # Comparison & Representation | |
| 163 def __eq__(self, other): | |
| 164 return (self is other) or ( | |
| 165 isinstance(other, Commit) and ( | |
| 166 self.hsh == other.hsh | |
| 167 ) | |
| 168 ) | |
| 169 | |
| 170 def __ne__(self, other): | |
| 171 return not (self == other) | |
| 172 | |
| 173 def __repr__(self): | |
| 174 return 'Commit({_repo!r}, {_hsh!r})'.format(**self.__dict__) | |
| 175 | |
| 176 # Accessors | |
| 177 # pylint: disable=W0212 | |
| 178 repo = property(lambda self: self._repo) | |
| 179 hsh = property(lambda self: self._hsh) | |
| 180 | |
| 181 # Properties | |
| 182 @cached_property | |
| 183 def data(self): | |
| 184 """Get a structured data representation of this commit.""" | |
| 185 try: | |
| 186 raw_data = self.repo.run('cat-file', 'commit', self.hsh) | |
| 187 except CalledProcessError: | |
| 188 return INVALID | |
| 189 return CommitData.from_raw(raw_data) | |
| 190 | |
| 191 @cached_property | |
| 192 def parent(self): | |
| 193 """Get the corresponding parent Commit() for this Commit(), or None. | |
| 194 | |
| 195 If self has more than one parent, this raises an Exception. | |
| 196 """ | |
| 197 parents = self.data.parents | |
| 198 if len(parents) > 1: | |
| 199 LOGGER.error('Commit %r has more than one parent!', self.hsh) | |
| 200 return INVALID | |
| 201 return self.repo.get_commit(parents[0]) if parents else None | |
| 202 | |
| 203 # Methods | |
| 204 def alter(self, **kwargs): | |
| 205 """Get a new Commit which is the same as this one, except for alterations | |
| 206 specified by kwargs. | |
| 207 | |
| 208 This will intern the new Commit object into the Repo. | |
| 209 """ | |
| 210 return self.repo.get_commit( | |
| 211 self.repo.intern(self.data.alter(**kwargs), 'commit')) | |
| 212 | |
| 213 | |
| 214 class Ref(object): | |
| 215 """Represents a single simple ref in a git Repo.""" | |
| 216 def __init__(self, repo, ref_str): | |
| 217 """ | |
| 218 @type repo: Repo | |
| 219 @type ref_str: str | |
| 220 """ | |
| 221 self._repo = repo | |
| 222 self._ref = ref_str | |
| 223 | |
| 224 # Comparison & Representation | |
| 225 def __eq__(self, other): | |
| 226 return (self is other) or ( | |
| 227 isinstance(other, Ref) and ( | |
| 228 self.ref == other.ref and | |
| 229 self.repo is other.repo | |
| 230 ) | |
| 231 ) | |
| 232 | |
| 233 def __ne__(self, other): | |
| 234 return not (self == other) | |
| 235 | |
| 236 def __repr__(self): | |
| 237 return 'Ref({_repo!r}, {_ref!r})'.format(**self.__dict__) | |
| 238 | |
| 239 # Accessors | |
| 240 # pylint: disable=W0212 | |
| 241 repo = property(lambda self: self._repo) | |
| 242 ref = property(lambda self: self._ref) | |
| 243 | |
| 244 # Properties | |
| 245 @property | |
| 246 def commit(self): | |
| 247 """Get the Commit at the tip of this Ref.""" | |
| 248 try: | |
| 249 val = self._repo.run('show-ref', '--verify', self._ref) | |
| 250 except CalledProcessError: | |
| 251 return INVALID | |
| 252 return self._repo.get_commit(val.split()[0]) | |
| 253 | |
| 254 # Methods | |
| 255 def to(self, other): | |
| 256 """Generate Commit()'s which occur from `self..other`.""" | |
| 257 assert self.commit is not INVALID | |
| 258 arg = '%s..%s' % (self.ref, other.ref) | |
| 259 for hsh in self.repo.run('rev-list', '--reverse', arg).splitlines(): | |
| 260 yield self.repo.get_commit(hsh) | |
| 261 | |
| 262 def fast_forward_push(self, commit): | |
| 263 """Push |commit| to this ref on the remote, and update the local copy of the | |
| 264 ref to |commit|.""" | |
| 265 self.repo.run('push', 'origin', '%s:%s' % (commit.hsh, self.ref)) | |
| 266 self.update_to(commit) | |
| 267 | |
| 268 def update_to(self, commit): | |
| 269 """Update the local copy of the ref to |commit|.""" | |
| 270 self.repo.run('update-ref', self.ref, commit.hsh) | |
| OLD | NEW |