##// END OF EJS Templates
git: enable unreachable commits search
marcink -
r848:b44073c0 default
parent child Browse files
Show More
@@ -1,1189 +1,1192 b''
1 1 # RhodeCode VCSServer provides access to different vcs backends via network.
2 2 # Copyright (C) 2014-2019 RhodeCode GmbH
3 3 #
4 4 # This program is free software; you can redistribute it and/or modify
5 5 # it under the terms of the GNU General Public License as published by
6 6 # the Free Software Foundation; either version 3 of the License, or
7 7 # (at your option) any later version.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU General Public License
15 15 # along with this program; if not, write to the Free Software Foundation,
16 16 # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
17 17
18 18 import collections
19 19 import logging
20 20 import os
21 21 import posixpath as vcspath
22 22 import re
23 23 import stat
24 24 import traceback
25 25 import urllib
26 26 import urllib2
27 27 from functools import wraps
28 28
29 29 import more_itertools
30 30 import pygit2
31 31 from pygit2 import Repository as LibGit2Repo
32 32 from dulwich import index, objects
33 33 from dulwich.client import HttpGitClient, LocalGitClient
34 34 from dulwich.errors import (
35 35 NotGitRepository, ChecksumMismatch, WrongObjectException,
36 36 MissingCommitError, ObjectMissing, HangupException,
37 37 UnexpectedCommandError)
38 38 from dulwich.repo import Repo as DulwichRepo
39 39 from dulwich.server import update_server_info
40 40
41 41 from vcsserver import exceptions, settings, subprocessio
42 42 from vcsserver.utils import safe_str, safe_int, safe_unicode
43 43 from vcsserver.base import RepoFactory, obfuscate_qs
44 44 from vcsserver.hgcompat import (
45 45 hg_url as url_parser, httpbasicauthhandler, httpdigestauthhandler)
46 46 from vcsserver.git_lfs.lib import LFSOidStore
47 47 from vcsserver.vcs_base import RemoteBase
48 48
49 49 DIR_STAT = stat.S_IFDIR
50 50 FILE_MODE = stat.S_IFMT
51 51 GIT_LINK = objects.S_IFGITLINK
52 52 PEELED_REF_MARKER = '^{}'
53 53
54 54
55 55 log = logging.getLogger(__name__)
56 56
57 57
58 58 def str_to_dulwich(value):
59 59 """
60 60 Dulwich 0.10.1a requires `unicode` objects to be passed in.
61 61 """
62 62 return value.decode(settings.WIRE_ENCODING)
63 63
64 64
65 65 def reraise_safe_exceptions(func):
66 66 """Converts Dulwich exceptions to something neutral."""
67 67
68 68 @wraps(func)
69 69 def wrapper(*args, **kwargs):
70 70 try:
71 71 return func(*args, **kwargs)
72 72 except (ChecksumMismatch, WrongObjectException, MissingCommitError, ObjectMissing,) as e:
73 73 exc = exceptions.LookupException(org_exc=e)
74 74 raise exc(safe_str(e))
75 75 except (HangupException, UnexpectedCommandError) as e:
76 76 exc = exceptions.VcsException(org_exc=e)
77 77 raise exc(safe_str(e))
78 78 except Exception as e:
79 79 # NOTE(marcink): becuase of how dulwich handles some exceptions
80 80 # (KeyError on empty repos), we cannot track this and catch all
81 81 # exceptions, it's an exceptions from other handlers
82 82 #if not hasattr(e, '_vcs_kind'):
83 83 #log.exception("Unhandled exception in git remote call")
84 84 #raise_from_original(exceptions.UnhandledException)
85 85 raise
86 86 return wrapper
87 87
88 88
89 89 class Repo(DulwichRepo):
90 90 """
91 91 A wrapper for dulwich Repo class.
92 92
93 93 Since dulwich is sometimes keeping .idx file descriptors open, it leads to
94 94 "Too many open files" error. We need to close all opened file descriptors
95 95 once the repo object is destroyed.
96 96 """
97 97 def __del__(self):
98 98 if hasattr(self, 'object_store'):
99 99 self.close()
100 100
101 101
102 102 class Repository(LibGit2Repo):
103 103
104 104 def __enter__(self):
105 105 return self
106 106
107 107 def __exit__(self, exc_type, exc_val, exc_tb):
108 108 self.free()
109 109
110 110
111 111 class GitFactory(RepoFactory):
112 112 repo_type = 'git'
113 113
114 114 def _create_repo(self, wire, create, use_libgit2=False):
115 115 if use_libgit2:
116 116 return Repository(wire['path'])
117 117 else:
118 118 repo_path = str_to_dulwich(wire['path'])
119 119 return Repo(repo_path)
120 120
121 121 def repo(self, wire, create=False, use_libgit2=False):
122 122 """
123 123 Get a repository instance for the given path.
124 124 """
125 125 return self._create_repo(wire, create, use_libgit2)
126 126
127 127 def repo_libgit2(self, wire):
128 128 return self.repo(wire, use_libgit2=True)
129 129
130 130
131 131 class GitRemote(RemoteBase):
132 132
133 133 def __init__(self, factory):
134 134 self._factory = factory
135 135 self._bulk_methods = {
136 136 "date": self.date,
137 137 "author": self.author,
138 138 "branch": self.branch,
139 139 "message": self.message,
140 140 "parents": self.parents,
141 141 "_commit": self.revision,
142 142 }
143 143
144 144 def _wire_to_config(self, wire):
145 145 if 'config' in wire:
146 146 return dict([(x[0] + '_' + x[1], x[2]) for x in wire['config']])
147 147 return {}
148 148
149 149 def _remote_conf(self, config):
150 150 params = [
151 151 '-c', 'core.askpass=""',
152 152 ]
153 153 ssl_cert_dir = config.get('vcs_ssl_dir')
154 154 if ssl_cert_dir:
155 155 params.extend(['-c', 'http.sslCAinfo={}'.format(ssl_cert_dir)])
156 156 return params
157 157
158 158 @reraise_safe_exceptions
159 159 def discover_git_version(self):
160 160 stdout, _ = self.run_git_command(
161 161 {}, ['--version'], _bare=True, _safe=True)
162 162 prefix = 'git version'
163 163 if stdout.startswith(prefix):
164 164 stdout = stdout[len(prefix):]
165 165 return stdout.strip()
166 166
167 167 @reraise_safe_exceptions
168 168 def is_empty(self, wire):
169 169 repo_init = self._factory.repo_libgit2(wire)
170 170 with repo_init as repo:
171 171
172 172 try:
173 173 has_head = repo.head.name
174 174 if has_head:
175 175 return False
176 176
177 177 # NOTE(marcink): check again using more expensive method
178 178 return repo.is_empty
179 179 except Exception:
180 180 pass
181 181
182 182 return True
183 183
184 184 @reraise_safe_exceptions
185 185 def assert_correct_path(self, wire):
186 186 cache_on, context_uid, repo_id = self._cache_on(wire)
187 187 @self.region.conditional_cache_on_arguments(condition=cache_on)
188 188 def _assert_correct_path(_context_uid, _repo_id):
189 189 try:
190 190 repo_init = self._factory.repo_libgit2(wire)
191 191 with repo_init as repo:
192 192 pass
193 193 except pygit2.GitError:
194 194 path = wire.get('path')
195 195 tb = traceback.format_exc()
196 196 log.debug("Invalid Git path `%s`, tb: %s", path, tb)
197 197 return False
198 198
199 199 return True
200 200 return _assert_correct_path(context_uid, repo_id)
201 201
202 202 @reraise_safe_exceptions
203 203 def bare(self, wire):
204 204 repo_init = self._factory.repo_libgit2(wire)
205 205 with repo_init as repo:
206 206 return repo.is_bare
207 207
208 208 @reraise_safe_exceptions
209 209 def blob_as_pretty_string(self, wire, sha):
210 210 repo_init = self._factory.repo_libgit2(wire)
211 211 with repo_init as repo:
212 212 blob_obj = repo[sha]
213 213 blob = blob_obj.data
214 214 return blob
215 215
216 216 @reraise_safe_exceptions
217 217 def blob_raw_length(self, wire, sha):
218 218 cache_on, context_uid, repo_id = self._cache_on(wire)
219 219 @self.region.conditional_cache_on_arguments(condition=cache_on)
220 220 def _blob_raw_length(_repo_id, _sha):
221 221
222 222 repo_init = self._factory.repo_libgit2(wire)
223 223 with repo_init as repo:
224 224 blob = repo[sha]
225 225 return blob.size
226 226
227 227 return _blob_raw_length(repo_id, sha)
228 228
229 229 def _parse_lfs_pointer(self, raw_content):
230 230
231 231 spec_string = 'version https://git-lfs.github.com/spec'
232 232 if raw_content and raw_content.startswith(spec_string):
233 233 pattern = re.compile(r"""
234 234 (?:\n)?
235 235 ^version[ ]https://git-lfs\.github\.com/spec/(?P<spec_ver>v\d+)\n
236 236 ^oid[ ] sha256:(?P<oid_hash>[0-9a-f]{64})\n
237 237 ^size[ ](?P<oid_size>[0-9]+)\n
238 238 (?:\n)?
239 239 """, re.VERBOSE | re.MULTILINE)
240 240 match = pattern.match(raw_content)
241 241 if match:
242 242 return match.groupdict()
243 243
244 244 return {}
245 245
246 246 @reraise_safe_exceptions
247 247 def is_large_file(self, wire, commit_id):
248 248 cache_on, context_uid, repo_id = self._cache_on(wire)
249 249
250 250 @self.region.conditional_cache_on_arguments(condition=cache_on)
251 251 def _is_large_file(_repo_id, _sha):
252 252 repo_init = self._factory.repo_libgit2(wire)
253 253 with repo_init as repo:
254 254 blob = repo[commit_id]
255 255 if blob.is_binary:
256 256 return {}
257 257
258 258 return self._parse_lfs_pointer(blob.data)
259 259
260 260 return _is_large_file(repo_id, commit_id)
261 261
262 262 @reraise_safe_exceptions
263 263 def is_binary(self, wire, tree_id):
264 264 cache_on, context_uid, repo_id = self._cache_on(wire)
265 265
266 266 @self.region.conditional_cache_on_arguments(condition=cache_on)
267 267 def _is_binary(_repo_id, _tree_id):
268 268 repo_init = self._factory.repo_libgit2(wire)
269 269 with repo_init as repo:
270 270 blob_obj = repo[tree_id]
271 271 return blob_obj.is_binary
272 272
273 273 return _is_binary(repo_id, tree_id)
274 274
275 275 @reraise_safe_exceptions
276 276 def in_largefiles_store(self, wire, oid):
277 277 conf = self._wire_to_config(wire)
278 278 repo_init = self._factory.repo_libgit2(wire)
279 279 with repo_init as repo:
280 280 repo_name = repo.path
281 281
282 282 store_location = conf.get('vcs_git_lfs_store_location')
283 283 if store_location:
284 284
285 285 store = LFSOidStore(
286 286 oid=oid, repo=repo_name, store_location=store_location)
287 287 return store.has_oid()
288 288
289 289 return False
290 290
291 291 @reraise_safe_exceptions
292 292 def store_path(self, wire, oid):
293 293 conf = self._wire_to_config(wire)
294 294 repo_init = self._factory.repo_libgit2(wire)
295 295 with repo_init as repo:
296 296 repo_name = repo.path
297 297
298 298 store_location = conf.get('vcs_git_lfs_store_location')
299 299 if store_location:
300 300 store = LFSOidStore(
301 301 oid=oid, repo=repo_name, store_location=store_location)
302 302 return store.oid_path
303 303 raise ValueError('Unable to fetch oid with path {}'.format(oid))
304 304
305 305 @reraise_safe_exceptions
306 306 def bulk_request(self, wire, rev, pre_load):
307 307 cache_on, context_uid, repo_id = self._cache_on(wire)
308 308 @self.region.conditional_cache_on_arguments(condition=cache_on)
309 309 def _bulk_request(_repo_id, _rev, _pre_load):
310 310 result = {}
311 311 for attr in pre_load:
312 312 try:
313 313 method = self._bulk_methods[attr]
314 314 args = [wire, rev]
315 315 result[attr] = method(*args)
316 316 except KeyError as e:
317 317 raise exceptions.VcsException(e)(
318 318 "Unknown bulk attribute: %s" % attr)
319 319 return result
320 320
321 321 return _bulk_request(repo_id, rev, sorted(pre_load))
322 322
323 323 def _build_opener(self, url):
324 324 handlers = []
325 325 url_obj = url_parser(url)
326 326 _, authinfo = url_obj.authinfo()
327 327
328 328 if authinfo:
329 329 # create a password manager
330 330 passmgr = urllib2.HTTPPasswordMgrWithDefaultRealm()
331 331 passmgr.add_password(*authinfo)
332 332
333 333 handlers.extend((httpbasicauthhandler(passmgr),
334 334 httpdigestauthhandler(passmgr)))
335 335
336 336 return urllib2.build_opener(*handlers)
337 337
338 338 def _type_id_to_name(self, type_id):
339 339 return {
340 340 1: b'commit',
341 341 2: b'tree',
342 342 3: b'blob',
343 343 4: b'tag'
344 344 }[type_id]
345 345
346 346 @reraise_safe_exceptions
347 347 def check_url(self, url, config):
348 348 url_obj = url_parser(url)
349 349 test_uri, _ = url_obj.authinfo()
350 350 url_obj.passwd = '*****' if url_obj.passwd else url_obj.passwd
351 351 url_obj.query = obfuscate_qs(url_obj.query)
352 352 cleaned_uri = str(url_obj)
353 353 log.info("Checking URL for remote cloning/import: %s", cleaned_uri)
354 354
355 355 if not test_uri.endswith('info/refs'):
356 356 test_uri = test_uri.rstrip('/') + '/info/refs'
357 357
358 358 o = self._build_opener(url)
359 359 o.addheaders = [('User-Agent', 'git/1.7.8.0')] # fake some git
360 360
361 361 q = {"service": 'git-upload-pack'}
362 362 qs = '?%s' % urllib.urlencode(q)
363 363 cu = "%s%s" % (test_uri, qs)
364 364 req = urllib2.Request(cu, None, {})
365 365
366 366 try:
367 367 log.debug("Trying to open URL %s", cleaned_uri)
368 368 resp = o.open(req)
369 369 if resp.code != 200:
370 370 raise exceptions.URLError()('Return Code is not 200')
371 371 except Exception as e:
372 372 log.warning("URL cannot be opened: %s", cleaned_uri, exc_info=True)
373 373 # means it cannot be cloned
374 374 raise exceptions.URLError(e)("[%s] org_exc: %s" % (cleaned_uri, e))
375 375
376 376 # now detect if it's proper git repo
377 377 gitdata = resp.read()
378 378 if 'service=git-upload-pack' in gitdata:
379 379 pass
380 380 elif re.findall(r'[0-9a-fA-F]{40}\s+refs', gitdata):
381 381 # old style git can return some other format !
382 382 pass
383 383 else:
384 384 raise exceptions.URLError()(
385 385 "url [%s] does not look like an git" % (cleaned_uri,))
386 386
387 387 return True
388 388
389 389 @reraise_safe_exceptions
390 390 def clone(self, wire, url, deferred, valid_refs, update_after_clone):
391 391 # TODO(marcink): deprecate this method. Last i checked we don't use it anymore
392 392 remote_refs = self.pull(wire, url, apply_refs=False)
393 393 repo = self._factory.repo(wire)
394 394 if isinstance(valid_refs, list):
395 395 valid_refs = tuple(valid_refs)
396 396
397 397 for k in remote_refs:
398 398 # only parse heads/tags and skip so called deferred tags
399 399 if k.startswith(valid_refs) and not k.endswith(deferred):
400 400 repo[k] = remote_refs[k]
401 401
402 402 if update_after_clone:
403 403 # we want to checkout HEAD
404 404 repo["HEAD"] = remote_refs["HEAD"]
405 405 index.build_index_from_tree(repo.path, repo.index_path(),
406 406 repo.object_store, repo["HEAD"].tree)
407 407
408 408 @reraise_safe_exceptions
409 409 def branch(self, wire, commit_id):
410 410 cache_on, context_uid, repo_id = self._cache_on(wire)
411 411 @self.region.conditional_cache_on_arguments(condition=cache_on)
412 412 def _branch(_context_uid, _repo_id, _commit_id):
413 413 regex = re.compile('^refs/heads')
414 414
415 415 def filter_with(ref):
416 416 return regex.match(ref[0]) and ref[1] == _commit_id
417 417
418 418 branches = filter(filter_with, self.get_refs(wire).items())
419 419 return [x[0].split('refs/heads/')[-1] for x in branches]
420 420
421 421 return _branch(context_uid, repo_id, commit_id)
422 422
423 423 @reraise_safe_exceptions
424 424 def commit_branches(self, wire, commit_id):
425 425 cache_on, context_uid, repo_id = self._cache_on(wire)
426 426 @self.region.conditional_cache_on_arguments(condition=cache_on)
427 427 def _commit_branches(_context_uid, _repo_id, _commit_id):
428 428 repo_init = self._factory.repo_libgit2(wire)
429 429 with repo_init as repo:
430 430 branches = [x for x in repo.branches.with_commit(_commit_id)]
431 431 return branches
432 432
433 433 return _commit_branches(context_uid, repo_id, commit_id)
434 434
435 435 @reraise_safe_exceptions
436 436 def add_object(self, wire, content):
437 437 repo_init = self._factory.repo_libgit2(wire)
438 438 with repo_init as repo:
439 439 blob = objects.Blob()
440 440 blob.set_raw_string(content)
441 441 repo.object_store.add_object(blob)
442 442 return blob.id
443 443
444 444 # TODO: this is quite complex, check if that can be simplified
445 445 @reraise_safe_exceptions
446 446 def commit(self, wire, commit_data, branch, commit_tree, updated, removed):
447 447 repo = self._factory.repo(wire)
448 448 object_store = repo.object_store
449 449
450 450 # Create tree and populates it with blobs
451 451 commit_tree = commit_tree and repo[commit_tree] or objects.Tree()
452 452
453 453 for node in updated:
454 454 # Compute subdirs if needed
455 455 dirpath, nodename = vcspath.split(node['path'])
456 456 dirnames = map(safe_str, dirpath and dirpath.split('/') or [])
457 457 parent = commit_tree
458 458 ancestors = [('', parent)]
459 459
460 460 # Tries to dig for the deepest existing tree
461 461 while dirnames:
462 462 curdir = dirnames.pop(0)
463 463 try:
464 464 dir_id = parent[curdir][1]
465 465 except KeyError:
466 466 # put curdir back into dirnames and stops
467 467 dirnames.insert(0, curdir)
468 468 break
469 469 else:
470 470 # If found, updates parent
471 471 parent = repo[dir_id]
472 472 ancestors.append((curdir, parent))
473 473 # Now parent is deepest existing tree and we need to create
474 474 # subtrees for dirnames (in reverse order)
475 475 # [this only applies for nodes from added]
476 476 new_trees = []
477 477
478 478 blob = objects.Blob.from_string(node['content'])
479 479
480 480 if dirnames:
481 481 # If there are trees which should be created we need to build
482 482 # them now (in reverse order)
483 483 reversed_dirnames = list(reversed(dirnames))
484 484 curtree = objects.Tree()
485 485 curtree[node['node_path']] = node['mode'], blob.id
486 486 new_trees.append(curtree)
487 487 for dirname in reversed_dirnames[:-1]:
488 488 newtree = objects.Tree()
489 489 newtree[dirname] = (DIR_STAT, curtree.id)
490 490 new_trees.append(newtree)
491 491 curtree = newtree
492 492 parent[reversed_dirnames[-1]] = (DIR_STAT, curtree.id)
493 493 else:
494 494 parent.add(name=node['node_path'], mode=node['mode'], hexsha=blob.id)
495 495
496 496 new_trees.append(parent)
497 497 # Update ancestors
498 498 reversed_ancestors = reversed(
499 499 [(a[1], b[1], b[0]) for a, b in zip(ancestors, ancestors[1:])])
500 500 for parent, tree, path in reversed_ancestors:
501 501 parent[path] = (DIR_STAT, tree.id)
502 502 object_store.add_object(tree)
503 503
504 504 object_store.add_object(blob)
505 505 for tree in new_trees:
506 506 object_store.add_object(tree)
507 507
508 508 for node_path in removed:
509 509 paths = node_path.split('/')
510 510 tree = commit_tree
511 511 trees = [tree]
512 512 # Traverse deep into the forest...
513 513 for path in paths:
514 514 try:
515 515 obj = repo[tree[path][1]]
516 516 if isinstance(obj, objects.Tree):
517 517 trees.append(obj)
518 518 tree = obj
519 519 except KeyError:
520 520 break
521 521 # Cut down the blob and all rotten trees on the way back...
522 522 for path, tree in reversed(zip(paths, trees)):
523 523 del tree[path]
524 524 if tree:
525 525 # This tree still has elements - don't remove it or any
526 526 # of it's parents
527 527 break
528 528
529 529 object_store.add_object(commit_tree)
530 530
531 531 # Create commit
532 532 commit = objects.Commit()
533 533 commit.tree = commit_tree.id
534 534 for k, v in commit_data.iteritems():
535 535 setattr(commit, k, v)
536 536 object_store.add_object(commit)
537 537
538 538 self.create_branch(wire, branch, commit.id)
539 539
540 540 # dulwich set-ref
541 541 ref = 'refs/heads/%s' % branch
542 542 repo.refs[ref] = commit.id
543 543
544 544 return commit.id
545 545
546 546 @reraise_safe_exceptions
547 547 def pull(self, wire, url, apply_refs=True, refs=None, update_after=False):
548 548 if url != 'default' and '://' not in url:
549 549 client = LocalGitClient(url)
550 550 else:
551 551 url_obj = url_parser(url)
552 552 o = self._build_opener(url)
553 553 url, _ = url_obj.authinfo()
554 554 client = HttpGitClient(base_url=url, opener=o)
555 555 repo = self._factory.repo(wire)
556 556
557 557 determine_wants = repo.object_store.determine_wants_all
558 558 if refs:
559 559 def determine_wants_requested(references):
560 560 return [references[r] for r in references if r in refs]
561 561 determine_wants = determine_wants_requested
562 562
563 563 try:
564 564 remote_refs = client.fetch(
565 565 path=url, target=repo, determine_wants=determine_wants)
566 566 except NotGitRepository as e:
567 567 log.warning(
568 568 'Trying to fetch from "%s" failed, not a Git repository.', url)
569 569 # Exception can contain unicode which we convert
570 570 raise exceptions.AbortException(e)(repr(e))
571 571
572 572 # mikhail: client.fetch() returns all the remote refs, but fetches only
573 573 # refs filtered by `determine_wants` function. We need to filter result
574 574 # as well
575 575 if refs:
576 576 remote_refs = {k: remote_refs[k] for k in remote_refs if k in refs}
577 577
578 578 if apply_refs:
579 579 # TODO: johbo: Needs proper test coverage with a git repository
580 580 # that contains a tag object, so that we would end up with
581 581 # a peeled ref at this point.
582 582 for k in remote_refs:
583 583 if k.endswith(PEELED_REF_MARKER):
584 584 log.debug("Skipping peeled reference %s", k)
585 585 continue
586 586 repo[k] = remote_refs[k]
587 587
588 588 if refs and not update_after:
589 589 # mikhail: explicitly set the head to the last ref.
590 590 repo['HEAD'] = remote_refs[refs[-1]]
591 591
592 592 if update_after:
593 593 # we want to checkout HEAD
594 594 repo["HEAD"] = remote_refs["HEAD"]
595 595 index.build_index_from_tree(repo.path, repo.index_path(),
596 596 repo.object_store, repo["HEAD"].tree)
597 597 return remote_refs
598 598
599 599 @reraise_safe_exceptions
600 600 def sync_fetch(self, wire, url, refs=None, all_refs=False):
601 601 repo = self._factory.repo(wire)
602 602 if refs and not isinstance(refs, (list, tuple)):
603 603 refs = [refs]
604 604
605 605 config = self._wire_to_config(wire)
606 606 # get all remote refs we'll use to fetch later
607 607 cmd = ['ls-remote']
608 608 if not all_refs:
609 609 cmd += ['--heads', '--tags']
610 610 cmd += [url]
611 611 output, __ = self.run_git_command(
612 612 wire, cmd, fail_on_stderr=False,
613 613 _copts=self._remote_conf(config),
614 614 extra_env={'GIT_TERMINAL_PROMPT': '0'})
615 615
616 616 remote_refs = collections.OrderedDict()
617 617 fetch_refs = []
618 618
619 619 for ref_line in output.splitlines():
620 620 sha, ref = ref_line.split('\t')
621 621 sha = sha.strip()
622 622 if ref in remote_refs:
623 623 # duplicate, skip
624 624 continue
625 625 if ref.endswith(PEELED_REF_MARKER):
626 626 log.debug("Skipping peeled reference %s", ref)
627 627 continue
628 628 # don't sync HEAD
629 629 if ref in ['HEAD']:
630 630 continue
631 631
632 632 remote_refs[ref] = sha
633 633
634 634 if refs and sha in refs:
635 635 # we filter fetch using our specified refs
636 636 fetch_refs.append('{}:{}'.format(ref, ref))
637 637 elif not refs:
638 638 fetch_refs.append('{}:{}'.format(ref, ref))
639 639 log.debug('Finished obtaining fetch refs, total: %s', len(fetch_refs))
640 640
641 641 if fetch_refs:
642 642 for chunk in more_itertools.chunked(fetch_refs, 1024 * 4):
643 643 fetch_refs_chunks = list(chunk)
644 644 log.debug('Fetching %s refs from import url', len(fetch_refs_chunks))
645 645 _out, _err = self.run_git_command(
646 646 wire, ['fetch', url, '--force', '--prune', '--'] + fetch_refs_chunks,
647 647 fail_on_stderr=False,
648 648 _copts=self._remote_conf(config),
649 649 extra_env={'GIT_TERMINAL_PROMPT': '0'})
650 650
651 651 return remote_refs
652 652
653 653 @reraise_safe_exceptions
654 654 def sync_push(self, wire, url, refs=None):
655 655 if not self.check_url(url, wire):
656 656 return
657 657 config = self._wire_to_config(wire)
658 658 self._factory.repo(wire)
659 659 self.run_git_command(
660 660 wire, ['push', url, '--mirror'], fail_on_stderr=False,
661 661 _copts=self._remote_conf(config),
662 662 extra_env={'GIT_TERMINAL_PROMPT': '0'})
663 663
664 664 @reraise_safe_exceptions
665 665 def get_remote_refs(self, wire, url):
666 666 repo = Repo(url)
667 667 return repo.get_refs()
668 668
669 669 @reraise_safe_exceptions
670 670 def get_description(self, wire):
671 671 repo = self._factory.repo(wire)
672 672 return repo.get_description()
673 673
674 674 @reraise_safe_exceptions
675 675 def get_missing_revs(self, wire, rev1, rev2, path2):
676 676 repo = self._factory.repo(wire)
677 677 LocalGitClient(thin_packs=False).fetch(path2, repo)
678 678
679 679 wire_remote = wire.copy()
680 680 wire_remote['path'] = path2
681 681 repo_remote = self._factory.repo(wire_remote)
682 682 LocalGitClient(thin_packs=False).fetch(wire["path"], repo_remote)
683 683
684 684 revs = [
685 685 x.commit.id
686 686 for x in repo_remote.get_walker(include=[rev2], exclude=[rev1])]
687 687 return revs
688 688
689 689 @reraise_safe_exceptions
690 def get_object(self, wire, sha):
690 def get_object(self, wire, sha, maybe_unreachable=False):
691 691 cache_on, context_uid, repo_id = self._cache_on(wire)
692 692 @self.region.conditional_cache_on_arguments(condition=cache_on)
693 693 def _get_object(_context_uid, _repo_id, _sha):
694 694 repo_init = self._factory.repo_libgit2(wire)
695 695 with repo_init as repo:
696 696
697 697 missing_commit_err = 'Commit {} does not exist for `{}`'.format(sha, wire['path'])
698 698 try:
699 699 commit = repo.revparse_single(sha)
700 700 except KeyError:
701 701 # NOTE(marcink): KeyError doesn't give us any meaningful information
702 702 # here, we instead give something more explicit
703 703 e = exceptions.RefNotFoundException('SHA: %s not found', sha)
704 704 raise exceptions.LookupException(e)(missing_commit_err)
705 705 except ValueError as e:
706 706 raise exceptions.LookupException(e)(missing_commit_err)
707 707
708 708 is_tag = False
709 709 if isinstance(commit, pygit2.Tag):
710 710 commit = repo.get(commit.target)
711 711 is_tag = True
712 712
713 713 check_dangling = True
714 714 if is_tag:
715 715 check_dangling = False
716 716
717 if check_dangling and maybe_unreachable:
718 check_dangling = False
719
717 720 # we used a reference and it parsed means we're not having a dangling commit
718 721 if sha != commit.hex:
719 722 check_dangling = False
720 723
721 724 if check_dangling:
722 725 # check for dangling commit
723 726 for branch in repo.branches.with_commit(commit.hex):
724 727 if branch:
725 728 break
726 729 else:
727 730 # NOTE(marcink): Empty error doesn't give us any meaningful information
728 731 # here, we instead give something more explicit
729 732 e = exceptions.RefNotFoundException('SHA: %s not found in branches', sha)
730 733 raise exceptions.LookupException(e)(missing_commit_err)
731 734
732 735 commit_id = commit.hex
733 736 type_id = commit.type
734 737
735 738 return {
736 739 'id': commit_id,
737 740 'type': self._type_id_to_name(type_id),
738 741 'commit_id': commit_id,
739 742 'idx': 0
740 743 }
741 744
742 745 return _get_object(context_uid, repo_id, sha)
743 746
744 747 @reraise_safe_exceptions
745 748 def get_refs(self, wire):
746 749 cache_on, context_uid, repo_id = self._cache_on(wire)
747 750 @self.region.conditional_cache_on_arguments(condition=cache_on)
748 751 def _get_refs(_context_uid, _repo_id):
749 752
750 753 repo_init = self._factory.repo_libgit2(wire)
751 754 with repo_init as repo:
752 755 regex = re.compile('^refs/(heads|tags)/')
753 756 return {x.name: x.target.hex for x in
754 757 filter(lambda ref: regex.match(ref.name) ,repo.listall_reference_objects())}
755 758
756 759 return _get_refs(context_uid, repo_id)
757 760
758 761 @reraise_safe_exceptions
759 762 def get_branch_pointers(self, wire):
760 763 cache_on, context_uid, repo_id = self._cache_on(wire)
761 764 @self.region.conditional_cache_on_arguments(condition=cache_on)
762 765 def _get_branch_pointers(_context_uid, _repo_id):
763 766
764 767 repo_init = self._factory.repo_libgit2(wire)
765 768 regex = re.compile('^refs/heads')
766 769 with repo_init as repo:
767 770 branches = filter(lambda ref: regex.match(ref.name), repo.listall_reference_objects())
768 771 return {x.target.hex: x.shorthand for x in branches}
769 772
770 773 return _get_branch_pointers(context_uid, repo_id)
771 774
772 775 @reraise_safe_exceptions
773 776 def head(self, wire, show_exc=True):
774 777 cache_on, context_uid, repo_id = self._cache_on(wire)
775 778 @self.region.conditional_cache_on_arguments(condition=cache_on)
776 779 def _head(_context_uid, _repo_id, _show_exc):
777 780 repo_init = self._factory.repo_libgit2(wire)
778 781 with repo_init as repo:
779 782 try:
780 783 return repo.head.peel().hex
781 784 except Exception:
782 785 if show_exc:
783 786 raise
784 787 return _head(context_uid, repo_id, show_exc)
785 788
786 789 @reraise_safe_exceptions
787 790 def init(self, wire):
788 791 repo_path = str_to_dulwich(wire['path'])
789 792 self.repo = Repo.init(repo_path)
790 793
791 794 @reraise_safe_exceptions
792 795 def init_bare(self, wire):
793 796 repo_path = str_to_dulwich(wire['path'])
794 797 self.repo = Repo.init_bare(repo_path)
795 798
796 799 @reraise_safe_exceptions
797 800 def revision(self, wire, rev):
798 801
799 802 cache_on, context_uid, repo_id = self._cache_on(wire)
800 803 @self.region.conditional_cache_on_arguments(condition=cache_on)
801 804 def _revision(_context_uid, _repo_id, _rev):
802 805 repo_init = self._factory.repo_libgit2(wire)
803 806 with repo_init as repo:
804 807 commit = repo[rev]
805 808 obj_data = {
806 809 'id': commit.id.hex,
807 810 }
808 811 # tree objects itself don't have tree_id attribute
809 812 if hasattr(commit, 'tree_id'):
810 813 obj_data['tree'] = commit.tree_id.hex
811 814
812 815 return obj_data
813 816 return _revision(context_uid, repo_id, rev)
814 817
815 818 @reraise_safe_exceptions
816 819 def date(self, wire, commit_id):
817 820 cache_on, context_uid, repo_id = self._cache_on(wire)
818 821 @self.region.conditional_cache_on_arguments(condition=cache_on)
819 822 def _date(_repo_id, _commit_id):
820 823 repo_init = self._factory.repo_libgit2(wire)
821 824 with repo_init as repo:
822 825 commit = repo[commit_id]
823 826
824 827 if hasattr(commit, 'commit_time'):
825 828 commit_time, commit_time_offset = commit.commit_time, commit.commit_time_offset
826 829 else:
827 830 commit = commit.get_object()
828 831 commit_time, commit_time_offset = commit.commit_time, commit.commit_time_offset
829 832
830 833 # TODO(marcink): check dulwich difference of offset vs timezone
831 834 return [commit_time, commit_time_offset]
832 835 return _date(repo_id, commit_id)
833 836
834 837 @reraise_safe_exceptions
835 838 def author(self, wire, commit_id):
836 839 cache_on, context_uid, repo_id = self._cache_on(wire)
837 840 @self.region.conditional_cache_on_arguments(condition=cache_on)
838 841 def _author(_repo_id, _commit_id):
839 842 repo_init = self._factory.repo_libgit2(wire)
840 843 with repo_init as repo:
841 844 commit = repo[commit_id]
842 845
843 846 if hasattr(commit, 'author'):
844 847 author = commit.author
845 848 else:
846 849 author = commit.get_object().author
847 850
848 851 if author.email:
849 852 return u"{} <{}>".format(author.name, author.email)
850 853
851 854 try:
852 855 return u"{}".format(author.name)
853 856 except Exception:
854 857 return u"{}".format(safe_unicode(author.raw_name))
855 858
856 859 return _author(repo_id, commit_id)
857 860
858 861 @reraise_safe_exceptions
859 862 def message(self, wire, commit_id):
860 863 cache_on, context_uid, repo_id = self._cache_on(wire)
861 864 @self.region.conditional_cache_on_arguments(condition=cache_on)
862 865 def _message(_repo_id, _commit_id):
863 866 repo_init = self._factory.repo_libgit2(wire)
864 867 with repo_init as repo:
865 868 commit = repo[commit_id]
866 869 return commit.message
867 870 return _message(repo_id, commit_id)
868 871
869 872 @reraise_safe_exceptions
870 873 def parents(self, wire, commit_id):
871 874 cache_on, context_uid, repo_id = self._cache_on(wire)
872 875 @self.region.conditional_cache_on_arguments(condition=cache_on)
873 876 def _parents(_repo_id, _commit_id):
874 877 repo_init = self._factory.repo_libgit2(wire)
875 878 with repo_init as repo:
876 879 commit = repo[commit_id]
877 880 if hasattr(commit, 'parent_ids'):
878 881 parent_ids = commit.parent_ids
879 882 else:
880 883 parent_ids = commit.get_object().parent_ids
881 884
882 885 return [x.hex for x in parent_ids]
883 886 return _parents(repo_id, commit_id)
884 887
885 888 @reraise_safe_exceptions
886 889 def children(self, wire, commit_id):
887 890 cache_on, context_uid, repo_id = self._cache_on(wire)
888 891 @self.region.conditional_cache_on_arguments(condition=cache_on)
889 892 def _children(_repo_id, _commit_id):
890 893 output, __ = self.run_git_command(
891 894 wire, ['rev-list', '--all', '--children'])
892 895
893 896 child_ids = []
894 897 pat = re.compile(r'^%s' % commit_id)
895 898 for l in output.splitlines():
896 899 if pat.match(l):
897 900 found_ids = l.split(' ')[1:]
898 901 child_ids.extend(found_ids)
899 902
900 903 return child_ids
901 904 return _children(repo_id, commit_id)
902 905
903 906 @reraise_safe_exceptions
904 907 def set_refs(self, wire, key, value):
905 908 repo_init = self._factory.repo_libgit2(wire)
906 909 with repo_init as repo:
907 910 repo.references.create(key, value, force=True)
908 911
909 912 @reraise_safe_exceptions
910 913 def create_branch(self, wire, branch_name, commit_id, force=False):
911 914 repo_init = self._factory.repo_libgit2(wire)
912 915 with repo_init as repo:
913 916 commit = repo[commit_id]
914 917
915 918 if force:
916 919 repo.branches.local.create(branch_name, commit, force=force)
917 920 elif not repo.branches.get(branch_name):
918 921 # create only if that branch isn't existing
919 922 repo.branches.local.create(branch_name, commit, force=force)
920 923
921 924 @reraise_safe_exceptions
922 925 def remove_ref(self, wire, key):
923 926 repo_init = self._factory.repo_libgit2(wire)
924 927 with repo_init as repo:
925 928 repo.references.delete(key)
926 929
927 930 @reraise_safe_exceptions
928 931 def tag_remove(self, wire, tag_name):
929 932 repo_init = self._factory.repo_libgit2(wire)
930 933 with repo_init as repo:
931 934 key = 'refs/tags/{}'.format(tag_name)
932 935 repo.references.delete(key)
933 936
934 937 @reraise_safe_exceptions
935 938 def tree_changes(self, wire, source_id, target_id):
936 939 # TODO(marcink): remove this seems it's only used by tests
937 940 repo = self._factory.repo(wire)
938 941 source = repo[source_id].tree if source_id else None
939 942 target = repo[target_id].tree
940 943 result = repo.object_store.tree_changes(source, target)
941 944 return list(result)
942 945
943 946 @reraise_safe_exceptions
944 947 def tree_and_type_for_path(self, wire, commit_id, path):
945 948
946 949 cache_on, context_uid, repo_id = self._cache_on(wire)
947 950 @self.region.conditional_cache_on_arguments(condition=cache_on)
948 951 def _tree_and_type_for_path(_context_uid, _repo_id, _commit_id, _path):
949 952 repo_init = self._factory.repo_libgit2(wire)
950 953
951 954 with repo_init as repo:
952 955 commit = repo[commit_id]
953 956 try:
954 957 tree = commit.tree[path]
955 958 except KeyError:
956 959 return None, None, None
957 960
958 961 return tree.id.hex, tree.type, tree.filemode
959 962 return _tree_and_type_for_path(context_uid, repo_id, commit_id, path)
960 963
961 964 @reraise_safe_exceptions
962 965 def tree_items(self, wire, tree_id):
963 966 cache_on, context_uid, repo_id = self._cache_on(wire)
964 967 @self.region.conditional_cache_on_arguments(condition=cache_on)
965 968 def _tree_items(_repo_id, _tree_id):
966 969
967 970 repo_init = self._factory.repo_libgit2(wire)
968 971 with repo_init as repo:
969 972 try:
970 973 tree = repo[tree_id]
971 974 except KeyError:
972 975 raise ObjectMissing('No tree with id: {}'.format(tree_id))
973 976
974 977 result = []
975 978 for item in tree:
976 979 item_sha = item.hex
977 980 item_mode = item.filemode
978 981 item_type = item.type
979 982
980 983 if item_type == 'commit':
981 984 # NOTE(marcink): submodules we translate to 'link' for backward compat
982 985 item_type = 'link'
983 986
984 987 result.append((item.name, item_mode, item_sha, item_type))
985 988 return result
986 989 return _tree_items(repo_id, tree_id)
987 990
988 991 @reraise_safe_exceptions
989 992 def diff_2(self, wire, commit_id_1, commit_id_2, file_filter, opt_ignorews, context):
990 993 """
991 994 Old version that uses subprocess to call diff
992 995 """
993 996
994 997 flags = [
995 998 '-U%s' % context, '--patch',
996 999 '--binary',
997 1000 '--find-renames',
998 1001 '--no-indent-heuristic',
999 1002 # '--indent-heuristic',
1000 1003 #'--full-index',
1001 1004 #'--abbrev=40'
1002 1005 ]
1003 1006
1004 1007 if opt_ignorews:
1005 1008 flags.append('--ignore-all-space')
1006 1009
1007 1010 if commit_id_1 == self.EMPTY_COMMIT:
1008 1011 cmd = ['show'] + flags + [commit_id_2]
1009 1012 else:
1010 1013 cmd = ['diff'] + flags + [commit_id_1, commit_id_2]
1011 1014
1012 1015 if file_filter:
1013 1016 cmd.extend(['--', file_filter])
1014 1017
1015 1018 diff, __ = self.run_git_command(wire, cmd)
1016 1019 # If we used 'show' command, strip first few lines (until actual diff
1017 1020 # starts)
1018 1021 if commit_id_1 == self.EMPTY_COMMIT:
1019 1022 lines = diff.splitlines()
1020 1023 x = 0
1021 1024 for line in lines:
1022 1025 if line.startswith('diff'):
1023 1026 break
1024 1027 x += 1
1025 1028 # Append new line just like 'diff' command do
1026 1029 diff = '\n'.join(lines[x:]) + '\n'
1027 1030 return diff
1028 1031
1029 1032 @reraise_safe_exceptions
1030 1033 def diff(self, wire, commit_id_1, commit_id_2, file_filter, opt_ignorews, context):
1031 1034 repo_init = self._factory.repo_libgit2(wire)
1032 1035 with repo_init as repo:
1033 1036 swap = True
1034 1037 flags = 0
1035 1038 flags |= pygit2.GIT_DIFF_SHOW_BINARY
1036 1039
1037 1040 if opt_ignorews:
1038 1041 flags |= pygit2.GIT_DIFF_IGNORE_WHITESPACE
1039 1042
1040 1043 if commit_id_1 == self.EMPTY_COMMIT:
1041 1044 comm1 = repo[commit_id_2]
1042 1045 diff_obj = comm1.tree.diff_to_tree(
1043 1046 flags=flags, context_lines=context, swap=swap)
1044 1047
1045 1048 else:
1046 1049 comm1 = repo[commit_id_2]
1047 1050 comm2 = repo[commit_id_1]
1048 1051 diff_obj = comm1.tree.diff_to_tree(
1049 1052 comm2.tree, flags=flags, context_lines=context, swap=swap)
1050 1053 similar_flags = 0
1051 1054 similar_flags |= pygit2.GIT_DIFF_FIND_RENAMES
1052 1055 diff_obj.find_similar(flags=similar_flags)
1053 1056
1054 1057 if file_filter:
1055 1058 for p in diff_obj:
1056 1059 if p.delta.old_file.path == file_filter:
1057 1060 return p.patch or ''
1058 1061 # fo matching path == no diff
1059 1062 return ''
1060 1063 return diff_obj.patch or ''
1061 1064
1062 1065 @reraise_safe_exceptions
1063 1066 def node_history(self, wire, commit_id, path, limit):
1064 1067 cache_on, context_uid, repo_id = self._cache_on(wire)
1065 1068 @self.region.conditional_cache_on_arguments(condition=cache_on)
1066 1069 def _node_history(_context_uid, _repo_id, _commit_id, _path, _limit):
1067 1070 # optimize for n==1, rev-list is much faster for that use-case
1068 1071 if limit == 1:
1069 1072 cmd = ['rev-list', '-1', commit_id, '--', path]
1070 1073 else:
1071 1074 cmd = ['log']
1072 1075 if limit:
1073 1076 cmd.extend(['-n', str(safe_int(limit, 0))])
1074 1077 cmd.extend(['--pretty=format: %H', '-s', commit_id, '--', path])
1075 1078
1076 1079 output, __ = self.run_git_command(wire, cmd)
1077 1080 commit_ids = re.findall(r'[0-9a-fA-F]{40}', output)
1078 1081
1079 1082 return [x for x in commit_ids]
1080 1083 return _node_history(context_uid, repo_id, commit_id, path, limit)
1081 1084
1082 1085 @reraise_safe_exceptions
1083 1086 def node_annotate(self, wire, commit_id, path):
1084 1087
1085 1088 cmd = ['blame', '-l', '--root', '-r', commit_id, '--', path]
1086 1089 # -l ==> outputs long shas (and we need all 40 characters)
1087 1090 # --root ==> doesn't put '^' character for boundaries
1088 1091 # -r commit_id ==> blames for the given commit
1089 1092 output, __ = self.run_git_command(wire, cmd)
1090 1093
1091 1094 result = []
1092 1095 for i, blame_line in enumerate(output.split('\n')[:-1]):
1093 1096 line_no = i + 1
1094 1097 commit_id, line = re.split(r' ', blame_line, 1)
1095 1098 result.append((line_no, commit_id, line))
1096 1099 return result
1097 1100
1098 1101 @reraise_safe_exceptions
1099 1102 def update_server_info(self, wire):
1100 1103 repo = self._factory.repo(wire)
1101 1104 update_server_info(repo)
1102 1105
1103 1106 @reraise_safe_exceptions
1104 1107 def get_all_commit_ids(self, wire):
1105 1108
1106 1109 cache_on, context_uid, repo_id = self._cache_on(wire)
1107 1110 @self.region.conditional_cache_on_arguments(condition=cache_on)
1108 1111 def _get_all_commit_ids(_context_uid, _repo_id):
1109 1112
1110 1113 cmd = ['rev-list', '--reverse', '--date-order', '--branches', '--tags']
1111 1114 try:
1112 1115 output, __ = self.run_git_command(wire, cmd)
1113 1116 return output.splitlines()
1114 1117 except Exception:
1115 1118 # Can be raised for empty repositories
1116 1119 return []
1117 1120 return _get_all_commit_ids(context_uid, repo_id)
1118 1121
1119 1122 @reraise_safe_exceptions
1120 1123 def run_git_command(self, wire, cmd, **opts):
1121 1124 path = wire.get('path', None)
1122 1125
1123 1126 if path and os.path.isdir(path):
1124 1127 opts['cwd'] = path
1125 1128
1126 1129 if '_bare' in opts:
1127 1130 _copts = []
1128 1131 del opts['_bare']
1129 1132 else:
1130 1133 _copts = ['-c', 'core.quotepath=false', ]
1131 1134 safe_call = False
1132 1135 if '_safe' in opts:
1133 1136 # no exc on failure
1134 1137 del opts['_safe']
1135 1138 safe_call = True
1136 1139
1137 1140 if '_copts' in opts:
1138 1141 _copts.extend(opts['_copts'] or [])
1139 1142 del opts['_copts']
1140 1143
1141 1144 gitenv = os.environ.copy()
1142 1145 gitenv.update(opts.pop('extra_env', {}))
1143 1146 # need to clean fix GIT_DIR !
1144 1147 if 'GIT_DIR' in gitenv:
1145 1148 del gitenv['GIT_DIR']
1146 1149 gitenv['GIT_CONFIG_NOGLOBAL'] = '1'
1147 1150 gitenv['GIT_DISCOVERY_ACROSS_FILESYSTEM'] = '1'
1148 1151
1149 1152 cmd = [settings.GIT_EXECUTABLE] + _copts + cmd
1150 1153 _opts = {'env': gitenv, 'shell': False}
1151 1154
1152 1155 proc = None
1153 1156 try:
1154 1157 _opts.update(opts)
1155 1158 proc = subprocessio.SubprocessIOChunker(cmd, **_opts)
1156 1159
1157 1160 return ''.join(proc), ''.join(proc.error)
1158 1161 except (EnvironmentError, OSError) as err:
1159 1162 cmd = ' '.join(cmd) # human friendly CMD
1160 1163 tb_err = ("Couldn't run git command (%s).\n"
1161 1164 "Original error was:%s\n"
1162 1165 "Call options:%s\n"
1163 1166 % (cmd, err, _opts))
1164 1167 log.exception(tb_err)
1165 1168 if safe_call:
1166 1169 return '', err
1167 1170 else:
1168 1171 raise exceptions.VcsException()(tb_err)
1169 1172 finally:
1170 1173 if proc:
1171 1174 proc.close()
1172 1175
1173 1176 @reraise_safe_exceptions
1174 1177 def install_hooks(self, wire, force=False):
1175 1178 from vcsserver.hook_utils import install_git_hooks
1176 1179 bare = self.bare(wire)
1177 1180 path = wire['path']
1178 1181 return install_git_hooks(path, bare, force_create=force)
1179 1182
1180 1183 @reraise_safe_exceptions
1181 1184 def get_hooks_info(self, wire):
1182 1185 from vcsserver.hook_utils import (
1183 1186 get_git_pre_hook_version, get_git_post_hook_version)
1184 1187 bare = self.bare(wire)
1185 1188 path = wire['path']
1186 1189 return {
1187 1190 'pre_version': get_git_pre_hook_version(path, bare),
1188 1191 'post_version': get_git_post_hook_version(path, bare),
1189 1192 }
General Comments 0
You need to be logged in to leave comments. Login now