##// END OF EJS Templates
pass in url for remote pull in git
marcink -
r2210:7b458dd6 beta
parent child Browse files
Show More
@@ -1,545 +1,546 b''
1 1 # -*- coding: utf-8 -*-
2 2 """
3 3 vcs.backends.git
4 4 ~~~~~~~~~~~~~~~~
5 5
6 6 Git backend implementation.
7 7
8 8 :created_on: Apr 8, 2010
9 9 :copyright: (c) 2010-2011 by Marcin Kuzminski, Lukasz Balcerzak.
10 10 """
11 11
12 12 import os
13 13 import re
14 14 import time
15 15 import posixpath
16 16 from dulwich.repo import Repo, NotGitRepository
17 17 #from dulwich.config import ConfigFile
18 18 from string import Template
19 19 from subprocess import Popen, PIPE
20 20 from rhodecode.lib.vcs.backends.base import BaseRepository
21 21 from rhodecode.lib.vcs.exceptions import BranchDoesNotExistError
22 22 from rhodecode.lib.vcs.exceptions import ChangesetDoesNotExistError
23 23 from rhodecode.lib.vcs.exceptions import EmptyRepositoryError
24 24 from rhodecode.lib.vcs.exceptions import RepositoryError
25 25 from rhodecode.lib.vcs.exceptions import TagAlreadyExistError
26 26 from rhodecode.lib.vcs.exceptions import TagDoesNotExistError
27 27 from rhodecode.lib.vcs.utils import safe_unicode, makedate, date_fromtimestamp
28 28 from rhodecode.lib.vcs.utils.lazy import LazyProperty
29 29 from rhodecode.lib.vcs.utils.ordered_dict import OrderedDict
30 30 from rhodecode.lib.vcs.utils.paths import abspath
31 31 from rhodecode.lib.vcs.utils.paths import get_user_home
32 32 from .workdir import GitWorkdir
33 33 from .changeset import GitChangeset
34 34 from .inmemory import GitInMemoryChangeset
35 35 from .config import ConfigFile
36 36
37 37
38 38 class GitRepository(BaseRepository):
39 39 """
40 40 Git repository backend.
41 41 """
42 42 DEFAULT_BRANCH_NAME = 'master'
43 43 scm = 'git'
44 44
45 45 def __init__(self, repo_path, create=False, src_url=None,
46 46 update_after_clone=False, bare=False):
47 47
48 48 self.path = abspath(repo_path)
49 49 self._repo = self._get_repo(create, src_url, update_after_clone, bare)
50 50 #temporary set that to now at later we will move it to constructor
51 51 baseui = None
52 52 if baseui is None:
53 53 from mercurial.ui import ui
54 54 baseui = ui()
55 55 # patch the instance of GitRepo with an "FAKE" ui object to add
56 56 # compatibility layer with Mercurial
57 57 setattr(self._repo, 'ui', baseui)
58 58
59 59 try:
60 60 self.head = self._repo.head()
61 61 except KeyError:
62 62 self.head = None
63 63
64 64 self._config_files = [
65 65 bare and abspath(self.path, 'config') or abspath(self.path, '.git',
66 66 'config'),
67 67 abspath(get_user_home(), '.gitconfig'),
68 68 ]
69 69
70 70 @LazyProperty
71 71 def revisions(self):
72 72 """
73 73 Returns list of revisions' ids, in ascending order. Being lazy
74 74 attribute allows external tools to inject shas from cache.
75 75 """
76 76 return self._get_all_revisions()
77 77
78 78 def run_git_command(self, cmd):
79 79 """
80 80 Runs given ``cmd`` as git command and returns tuple
81 81 (returncode, stdout, stderr).
82 82
83 83 .. note::
84 84 This method exists only until log/blame functionality is implemented
85 85 at Dulwich (see https://bugs.launchpad.net/bugs/645142). Parsing
86 86 os command's output is road to hell...
87 87
88 88 :param cmd: git command to be executed
89 89 """
90 90
91 91 _copts = ['-c', 'core.quotepath=false', ]
92 92 _str_cmd = False
93 93 if isinstance(cmd, basestring):
94 94 cmd = [cmd]
95 95 _str_cmd = True
96 96
97 97 cmd = ['GIT_CONFIG_NOGLOBAL=1', 'git'] + _copts + cmd
98 98 if _str_cmd:
99 99 cmd = ' '.join(cmd)
100 100 try:
101 101 opts = dict(
102 102 shell=isinstance(cmd, basestring),
103 103 stdout=PIPE,
104 104 stderr=PIPE)
105 105 if os.path.isdir(self.path):
106 106 opts['cwd'] = self.path
107 107 p = Popen(cmd, **opts)
108 108 except OSError, err:
109 109 raise RepositoryError("Couldn't run git command (%s).\n"
110 110 "Original error was:%s" % (cmd, err))
111 111 so, se = p.communicate()
112 112 if not se.startswith("fatal: bad default revision 'HEAD'") and \
113 113 p.returncode != 0:
114 114 raise RepositoryError("Couldn't run git command (%s).\n"
115 115 "stderr:\n%s" % (cmd, se))
116 116 return so, se
117 117
118 118 def _check_url(self, url):
119 119 """
120 120 Functon will check given url and try to verify if it's a valid
121 121 link. Sometimes it may happened that mercurial will issue basic
122 122 auth request that can cause whole API to hang when used from python
123 123 or other external calls.
124 124
125 125 On failures it'll raise urllib2.HTTPError
126 126 """
127 127
128 128 #TODO: implement this
129 129 pass
130 130
131 131 def _get_repo(self, create, src_url=None, update_after_clone=False,
132 132 bare=False):
133 133 if create and os.path.exists(self.path):
134 134 raise RepositoryError("Location already exist")
135 135 if src_url and not create:
136 136 raise RepositoryError("Create should be set to True if src_url is "
137 137 "given (clone operation creates repository)")
138 138 try:
139 139 if create and src_url:
140 140 self._check_url(src_url)
141 141 self.clone(src_url, update_after_clone, bare)
142 142 return Repo(self.path)
143 143 elif create:
144 144 os.mkdir(self.path)
145 145 if bare:
146 146 return Repo.init_bare(self.path)
147 147 else:
148 148 return Repo.init(self.path)
149 149 else:
150 150 return Repo(self.path)
151 151 except (NotGitRepository, OSError), err:
152 152 raise RepositoryError(err)
153 153
154 154 def _get_all_revisions(self):
155 155 cmd = 'rev-list --all --date-order'
156 156 try:
157 157 so, se = self.run_git_command(cmd)
158 158 except RepositoryError:
159 159 # Can be raised for empty repositories
160 160 return []
161 161 revisions = so.splitlines()
162 162 revisions.reverse()
163 163 return revisions
164 164
165 165 def _get_revision(self, revision):
166 166 """
167 167 For git backend we always return integer here. This way we ensure
168 168 that changset's revision attribute would become integer.
169 169 """
170 170 pattern = re.compile(r'^[[0-9a-fA-F]{12}|[0-9a-fA-F]{40}]$')
171 171 is_bstr = lambda o: isinstance(o, (str, unicode))
172 172 is_null = lambda o: len(o) == revision.count('0')
173 173
174 174 if len(self.revisions) == 0:
175 175 raise EmptyRepositoryError("There are no changesets yet")
176 176
177 177 if revision in (None, '', 'tip', 'HEAD', 'head', -1):
178 178 revision = self.revisions[-1]
179 179
180 180 if ((is_bstr(revision) and revision.isdigit() and len(revision) < 12)
181 181 or isinstance(revision, int) or is_null(revision)):
182 182 try:
183 183 revision = self.revisions[int(revision)]
184 184 except:
185 185 raise ChangesetDoesNotExistError("Revision %r does not exist "
186 186 "for this repository %s" % (revision, self))
187 187
188 188 elif is_bstr(revision):
189 189 if not pattern.match(revision) or revision not in self.revisions:
190 190 raise ChangesetDoesNotExistError("Revision %r does not exist "
191 191 "for this repository %s" % (revision, self))
192 192
193 193 # Ensure we return full id
194 194 if not pattern.match(str(revision)):
195 195 raise ChangesetDoesNotExistError("Given revision %r not recognized"
196 196 % revision)
197 197 return revision
198 198
199 199 def _get_archives(self, archive_name='tip'):
200 200
201 201 for i in [('zip', '.zip'), ('gz', '.tar.gz'), ('bz2', '.tar.bz2')]:
202 202 yield {"type": i[0], "extension": i[1], "node": archive_name}
203 203
204 204 def _get_url(self, url):
205 205 """
206 206 Returns normalized url. If schema is not given, would fall to
207 207 filesystem (``file:///``) schema.
208 208 """
209 209 url = str(url)
210 210 if url != 'default' and not '://' in url:
211 211 url = ':///'.join(('file', url))
212 212 return url
213 213
214 214 @LazyProperty
215 215 def name(self):
216 216 return os.path.basename(self.path)
217 217
218 218 @LazyProperty
219 219 def last_change(self):
220 220 """
221 221 Returns last change made on this repository as datetime object
222 222 """
223 223 return date_fromtimestamp(self._get_mtime(), makedate()[1])
224 224
225 225 def _get_mtime(self):
226 226 try:
227 227 return time.mktime(self.get_changeset().date.timetuple())
228 228 except RepositoryError:
229 229 # fallback to filesystem
230 230 in_path = os.path.join(self.path, '.git', "index")
231 231 he_path = os.path.join(self.path, '.git', "HEAD")
232 232 if os.path.exists(in_path):
233 233 return os.stat(in_path).st_mtime
234 234 else:
235 235 return os.stat(he_path).st_mtime
236 236
237 237 @LazyProperty
238 238 def description(self):
239 239 undefined_description = u'unknown'
240 240 description_path = os.path.join(self.path, '.git', 'description')
241 241 if os.path.isfile(description_path):
242 242 return safe_unicode(open(description_path).read())
243 243 else:
244 244 return undefined_description
245 245
246 246 @LazyProperty
247 247 def contact(self):
248 248 undefined_contact = u'Unknown'
249 249 return undefined_contact
250 250
251 251 @property
252 252 def branches(self):
253 253 if not self.revisions:
254 254 return {}
255 255 refs = self._repo.refs.as_dict()
256 256 sortkey = lambda ctx: ctx[0]
257 257 _branches = [('/'.join(ref.split('/')[2:]), head)
258 258 for ref, head in refs.items()
259 259 if ref.startswith('refs/heads/') and not ref.endswith('/HEAD')]
260 260 return OrderedDict(sorted(_branches, key=sortkey, reverse=False))
261 261
262 262 def _heads(self, reverse=False):
263 263 refs = self._repo.get_refs()
264 264 heads = {}
265 265
266 266 for key, val in refs.items():
267 267 for ref_key in ['refs/heads/', 'refs/remotes/origin/']:
268 268 if key.startswith(ref_key):
269 269 n = key[len(ref_key):]
270 270 if n not in ['HEAD']:
271 271 heads[n] = val
272 272
273 273 return heads if reverse else dict((y,x) for x,y in heads.iteritems())
274 274
275 275 def _get_tags(self):
276 276 if not self.revisions:
277 277 return {}
278 278 sortkey = lambda ctx: ctx[0]
279 279 _tags = [('/'.join(ref.split('/')[2:]), head) for ref, head in
280 280 self._repo.get_refs().items() if ref.startswith('refs/tags/')]
281 281 return OrderedDict(sorted(_tags, key=sortkey, reverse=True))
282 282
283 283 @LazyProperty
284 284 def tags(self):
285 285 return self._get_tags()
286 286
287 287 def tag(self, name, user, revision=None, message=None, date=None,
288 288 **kwargs):
289 289 """
290 290 Creates and returns a tag for the given ``revision``.
291 291
292 292 :param name: name for new tag
293 293 :param user: full username, i.e.: "Joe Doe <joe.doe@example.com>"
294 294 :param revision: changeset id for which new tag would be created
295 295 :param message: message of the tag's commit
296 296 :param date: date of tag's commit
297 297
298 298 :raises TagAlreadyExistError: if tag with same name already exists
299 299 """
300 300 if name in self.tags:
301 301 raise TagAlreadyExistError("Tag %s already exists" % name)
302 302 changeset = self.get_changeset(revision)
303 303 message = message or "Added tag %s for commit %s" % (name,
304 304 changeset.raw_id)
305 305 self._repo.refs["refs/tags/%s" % name] = changeset._commit.id
306 306
307 307 self.tags = self._get_tags()
308 308 return changeset
309 309
310 310 def remove_tag(self, name, user, message=None, date=None):
311 311 """
312 312 Removes tag with the given ``name``.
313 313
314 314 :param name: name of the tag to be removed
315 315 :param user: full username, i.e.: "Joe Doe <joe.doe@example.com>"
316 316 :param message: message of the tag's removal commit
317 317 :param date: date of tag's removal commit
318 318
319 319 :raises TagDoesNotExistError: if tag with given name does not exists
320 320 """
321 321 if name not in self.tags:
322 322 raise TagDoesNotExistError("Tag %s does not exist" % name)
323 323 tagpath = posixpath.join(self._repo.refs.path, 'refs', 'tags', name)
324 324 try:
325 325 os.remove(tagpath)
326 326 self.tags = self._get_tags()
327 327 except OSError, e:
328 328 raise RepositoryError(e.strerror)
329 329
330 330 def get_changeset(self, revision=None):
331 331 """
332 332 Returns ``GitChangeset`` object representing commit from git repository
333 333 at the given revision or head (most recent commit) if None given.
334 334 """
335 335 if isinstance(revision, GitChangeset):
336 336 return revision
337 337 revision = self._get_revision(revision)
338 338 changeset = GitChangeset(repository=self, revision=revision)
339 339 return changeset
340 340
341 341 def get_changesets(self, start=None, end=None, start_date=None,
342 342 end_date=None, branch_name=None, reverse=False):
343 343 """
344 344 Returns iterator of ``GitChangeset`` objects from start to end (both
345 345 are inclusive), in ascending date order (unless ``reverse`` is set).
346 346
347 347 :param start: changeset ID, as str; first returned changeset
348 348 :param end: changeset ID, as str; last returned changeset
349 349 :param start_date: if specified, changesets with commit date less than
350 350 ``start_date`` would be filtered out from returned set
351 351 :param end_date: if specified, changesets with commit date greater than
352 352 ``end_date`` would be filtered out from returned set
353 353 :param branch_name: if specified, changesets not reachable from given
354 354 branch would be filtered out from returned set
355 355 :param reverse: if ``True``, returned generator would be reversed
356 356 (meaning that returned changesets would have descending date order)
357 357
358 358 :raise BranchDoesNotExistError: If given ``branch_name`` does not
359 359 exist.
360 360 :raise ChangesetDoesNotExistError: If changeset for given ``start`` or
361 361 ``end`` could not be found.
362 362
363 363 """
364 364 if branch_name and branch_name not in self.branches:
365 365 raise BranchDoesNotExistError("Branch '%s' not found" \
366 366 % branch_name)
367 367 # %H at format means (full) commit hash, initial hashes are retrieved
368 368 # in ascending date order
369 369 cmd_template = 'log --date-order --reverse --pretty=format:"%H"'
370 370 cmd_params = {}
371 371 if start_date:
372 372 cmd_template += ' --since "$since"'
373 373 cmd_params['since'] = start_date.strftime('%m/%d/%y %H:%M:%S')
374 374 if end_date:
375 375 cmd_template += ' --until "$until"'
376 376 cmd_params['until'] = end_date.strftime('%m/%d/%y %H:%M:%S')
377 377 if branch_name:
378 378 cmd_template += ' $branch_name'
379 379 cmd_params['branch_name'] = branch_name
380 380 else:
381 381 cmd_template += ' --all'
382 382
383 383 cmd = Template(cmd_template).safe_substitute(**cmd_params)
384 384 revs = self.run_git_command(cmd)[0].splitlines()
385 385 start_pos = 0
386 386 end_pos = len(revs)
387 387 if start:
388 388 _start = self._get_revision(start)
389 389 try:
390 390 start_pos = revs.index(_start)
391 391 except ValueError:
392 392 pass
393 393
394 394 if end is not None:
395 395 _end = self._get_revision(end)
396 396 try:
397 397 end_pos = revs.index(_end)
398 398 except ValueError:
399 399 pass
400 400
401 401 if None not in [start, end] and start_pos > end_pos:
402 402 raise RepositoryError('start cannot be after end')
403 403
404 404 if end_pos is not None:
405 405 end_pos += 1
406 406
407 407 revs = revs[start_pos:end_pos]
408 408 if reverse:
409 409 revs = reversed(revs)
410 410 for rev in revs:
411 411 yield self.get_changeset(rev)
412 412
413 413 def get_diff(self, rev1, rev2, path=None, ignore_whitespace=False,
414 414 context=3):
415 415 """
416 416 Returns (git like) *diff*, as plain text. Shows changes introduced by
417 417 ``rev2`` since ``rev1``.
418 418
419 419 :param rev1: Entry point from which diff is shown. Can be
420 420 ``self.EMPTY_CHANGESET`` - in this case, patch showing all
421 421 the changes since empty state of the repository until ``rev2``
422 422 :param rev2: Until which revision changes should be shown.
423 423 :param ignore_whitespace: If set to ``True``, would not show whitespace
424 424 changes. Defaults to ``False``.
425 425 :param context: How many lines before/after changed lines should be
426 426 shown. Defaults to ``3``.
427 427 """
428 428 flags = ['-U%s' % context]
429 429 if ignore_whitespace:
430 430 flags.append('-w')
431 431
432 432 if rev1 == self.EMPTY_CHANGESET:
433 433 rev2 = self.get_changeset(rev2).raw_id
434 434 cmd = ' '.join(['show'] + flags + [rev2])
435 435 else:
436 436 rev1 = self.get_changeset(rev1).raw_id
437 437 rev2 = self.get_changeset(rev2).raw_id
438 438 cmd = ' '.join(['diff'] + flags + [rev1, rev2])
439 439
440 440 if path:
441 441 cmd += ' -- "%s"' % path
442 442 stdout, stderr = self.run_git_command(cmd)
443 443 # If we used 'show' command, strip first few lines (until actual diff
444 444 # starts)
445 445 if rev1 == self.EMPTY_CHANGESET:
446 446 lines = stdout.splitlines()
447 447 x = 0
448 448 for line in lines:
449 449 if line.startswith('diff'):
450 450 break
451 451 x += 1
452 452 # Append new line just like 'diff' command do
453 453 stdout = '\n'.join(lines[x:]) + '\n'
454 454 return stdout
455 455
456 456 @LazyProperty
457 457 def in_memory_changeset(self):
458 458 """
459 459 Returns ``GitInMemoryChangeset`` object for this repository.
460 460 """
461 461 return GitInMemoryChangeset(self)
462 462
463 463 def clone(self, url, update_after_clone=True, bare=False):
464 464 """
465 465 Tries to clone changes from external location.
466 466
467 467 :param update_after_clone: If set to ``False``, git won't checkout
468 468 working directory
469 469 :param bare: If set to ``True``, repository would be cloned into
470 470 *bare* git repository (no working directory at all).
471 471 """
472 472 url = self._get_url(url)
473 473 cmd = ['clone']
474 474 if bare:
475 475 cmd.append('--bare')
476 476 elif not update_after_clone:
477 477 cmd.append('--no-checkout')
478 478 cmd += ['--', '"%s"' % url, '"%s"' % self.path]
479 479 cmd = ' '.join(cmd)
480 480 # If error occurs run_git_command raises RepositoryError already
481 481 self.run_git_command(cmd)
482 482
483 483 def pull(self, url):
484 484 """
485 485 Tries to pull changes from external location.
486 486 """
487 487 url = self._get_url(url)
488 488 cmd = ['pull']
489 489 cmd.append("--ff-only")
490 cmd.append(url)
490 491 cmd = ' '.join(cmd)
491 492 # If error occurs run_git_command raises RepositoryError already
492 493 self.run_git_command(cmd)
493 494
494 495 @LazyProperty
495 496 def workdir(self):
496 497 """
497 498 Returns ``Workdir`` instance for this repository.
498 499 """
499 500 return GitWorkdir(self)
500 501
501 502 def get_config_value(self, section, name, config_file=None):
502 503 """
503 504 Returns configuration value for a given [``section``] and ``name``.
504 505
505 506 :param section: Section we want to retrieve value from
506 507 :param name: Name of configuration we want to retrieve
507 508 :param config_file: A path to file which should be used to retrieve
508 509 configuration from (might also be a list of file paths)
509 510 """
510 511 if config_file is None:
511 512 config_file = []
512 513 elif isinstance(config_file, basestring):
513 514 config_file = [config_file]
514 515
515 516 def gen_configs():
516 517 for path in config_file + self._config_files:
517 518 try:
518 519 yield ConfigFile.from_path(path)
519 520 except (IOError, OSError, ValueError):
520 521 continue
521 522
522 523 for config in gen_configs():
523 524 try:
524 525 return config.get(section, name)
525 526 except KeyError:
526 527 continue
527 528 return None
528 529
529 530 def get_user_name(self, config_file=None):
530 531 """
531 532 Returns user's name from global configuration file.
532 533
533 534 :param config_file: A path to file which should be used to retrieve
534 535 configuration from (might also be a list of file paths)
535 536 """
536 537 return self.get_config_value('user', 'name', config_file)
537 538
538 539 def get_user_email(self, config_file=None):
539 540 """
540 541 Returns user's email from global configuration file.
541 542
542 543 :param config_file: A path to file which should be used to retrieve
543 544 configuration from (might also be a list of file paths)
544 545 """
545 546 return self.get_config_value('user', 'email', config_file)
General Comments 0
You need to be logged in to leave comments. Login now