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