1
# Copyright (C) 2007-2018 Jelmer Vernooij <jelmer@jelmer.uk>
3
# This program is free software; you can redistribute it and/or modify
4
# it under the terms of the GNU General Public License as published by
5
# the Free Software Foundation; either version 2 of the License, or
6
# (at your option) any later version.
8
# This program is distributed in the hope that it will be useful,
9
# but WITHOUT ANY WARRANTY; without even the implied warranty of
10
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11
# GNU General Public License for more details.
13
# You should have received a copy of the GNU General Public License
14
# along with this program; if not, write to the Free Software
15
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
17
"""Remote dirs, repositories and branches."""
19
from __future__ import absolute_import
21
from io import BytesIO
36
from ..errors import (
49
UninitializableFormat,
51
from ..revisiontree import RevisionTree
52
from ..sixish import text_type
53
from ..transport import (
55
register_urlparse_netloc_protocol,
60
user_agent_for_github,
74
BareLocalGitControlDirFormat,
77
GitSmartRemoteNotSupported,
80
from .mapping import (
83
from .object_store import (
89
from .repository import (
100
import dulwich.client
101
from dulwich.errors import (
104
from dulwich.pack import (
106
pack_objects_to_data,
108
from dulwich.protocol import ZERO_SHA
109
from dulwich.refs import (
113
from dulwich.repo import (
122
import urllib.parse as urlparse
123
from urllib.parse import splituser, splitnport
126
from urllib import splituser, splitnport
128
# urlparse only supports a limited number of schemes by default
129
register_urlparse_netloc_protocol('git')
130
register_urlparse_netloc_protocol('git+ssh')
132
from dulwich.pack import load_pack_index
135
class GitPushResult(PushResult):
137
def _lookup_revno(self, revid):
139
return _quick_lookup_revno(self.source_branch, self.target_branch,
141
except GitSmartRemoteNotSupported:
146
return self._lookup_revno(self.old_revid)
150
return self._lookup_revno(self.new_revid)
153
# Don't run any tests on GitSmartTransport as it is not intended to be
154
# a full implementation of Transport
155
def get_test_permutations():
159
def split_git_url(url):
163
:return: Tuple with host, port, username, path.
165
(scheme, netloc, loc, _, _) = urlparse.urlsplit(url)
166
path = urlparse.unquote(loc)
167
if path.startswith("/~"):
169
(username, hostport) = splituser(netloc)
170
(host, port) = splitnport(hostport, None)
171
return (host, port, username, path)
174
class RemoteGitError(BzrError):
176
_fmt = "Remote server error: %(msg)s"
179
class HeadUpdateFailed(BzrError):
181
_fmt = ("Unable to update remote HEAD branch. To update the master "
182
"branch, specify the URL %(base_url)s,branch=master.")
184
def __init__(self, base_url):
185
super(HeadUpdateFailed, self).__init__()
186
self.base_url = base_url
189
def parse_git_error(url, message):
190
"""Parse a remote git server error and return a bzr exception.
192
:param url: URL of the remote repository
193
:param message: Message sent by the remote git server
195
message = str(message).strip()
196
if (message.startswith("Could not find Repository ") or
197
message == 'Repository not found.' or
198
(message.startswith('Repository ') and message.endswith(' not found.'))):
199
return NotBranchError(url, message)
200
if message == "HEAD failed to update":
201
base_url, _ = urlutils.split_segment_parameters(url)
202
return HeadUpdateFailed(base_url)
203
if message.startswith('access denied or repository not exported:'):
204
extra, path = message.split(': ', 1)
205
return PermissionDenied(path, extra)
206
if message.endswith('You are not allowed to push code to this project.'):
207
return PermissionDenied(url, message)
208
if message.endswith(' does not appear to be a git repository'):
209
return NotBranchError(url, message)
210
m = re.match(r'Permission to ([^ ]+) denied to ([^ ]+)\.', message)
212
return PermissionDenied(m.group(1), 'denied to %s' % m.group(2))
213
# Don't know, just return it to the user as-is
214
return RemoteGitError(message)
217
class GitSmartTransport(Transport):
219
def __init__(self, url, _client=None):
220
Transport.__init__(self, url)
221
(self._host, self._port, self._username, self._path) = \
223
if 'transport' in debug.debug_flags:
224
trace.mutter('host: %r, user: %r, port: %r, path: %r',
225
self._host, self._username, self._port, self._path)
226
self._client = _client
227
self._stripped_path = self._path.rsplit(",", 1)[0]
229
def external_url(self):
232
def has(self, relpath):
235
def _get_client(self):
236
raise NotImplementedError(self._get_client)
239
return self._stripped_path
242
raise NoSuchFile(path)
244
def abspath(self, relpath):
245
return urlutils.join(self.base, relpath)
247
def clone(self, offset=None):
248
"""See Transport.clone()."""
252
newurl = urlutils.join(self.base, offset)
254
return self.__class__(newurl, self._client)
257
class TCPGitSmartTransport(GitSmartTransport):
261
def _get_client(self):
262
if self._client is not None:
267
# return dulwich.client.LocalGitClient()
268
return dulwich.client.SubprocessGitClient()
269
return dulwich.client.TCPGitClient(self._host, self._port,
270
report_activity=self._report_activity)
273
class SSHSocketWrapper(object):
275
def __init__(self, sock):
278
def read(self, len=None):
279
return self.sock.recv(len)
281
def write(self, data):
282
return self.sock.write(data)
285
return len(select.select([self.sock.fileno()], [], [], 0)[0]) > 0
288
class DulwichSSHVendor(dulwich.client.SSHVendor):
291
from ..transport import ssh
292
self.bzr_ssh_vendor = ssh._get_ssh_vendor()
294
def run_command(self, host, command, username=None, port=None):
295
connection = self.bzr_ssh_vendor.connect_ssh(username=username,
296
password=None, port=port, host=host, command=command)
297
(kind, io_object) = connection.get_sock_or_pipes()
299
return SSHSocketWrapper(io_object)
301
raise AssertionError("Unknown io object kind %r'" % kind)
304
#dulwich.client.get_ssh_vendor = DulwichSSHVendor
307
class SSHGitSmartTransport(GitSmartTransport):
312
path = self._stripped_path
313
if path.startswith("/~/"):
317
def _get_client(self):
318
if self._client is not None:
322
location_config = config.LocationConfig(self.base)
323
client = dulwich.client.SSHGitClient(self._host, self._port, self._username,
324
report_activity=self._report_activity)
325
# Set up alternate pack program paths
326
upload_pack = location_config.get_user_option('git_upload_pack')
328
client.alternative_paths["upload-pack"] = upload_pack
329
receive_pack = location_config.get_user_option('git_receive_pack')
331
client.alternative_paths["receive-pack"] = receive_pack
335
class RemoteGitBranchFormat(GitBranchFormat):
337
def get_format_description(self):
338
return 'Remote Git Branch'
341
def _matchingcontroldir(self):
342
return RemoteGitControlDirFormat()
344
def initialize(self, a_controldir, name=None, repository=None,
345
append_revisions_only=None):
346
raise UninitializableFormat(self)
349
class DefaultProgressReporter(object):
351
_GIT_PROGRESS_PARTIAL_RE = re.compile(r"(.*?): +(\d+)% \((\d+)/(\d+)\)")
352
_GIT_PROGRESS_TOTAL_RE = re.compile(r"(.*?): (\d+)")
354
def __init__(self, pb):
357
def progress(self, text):
358
text = text.rstrip(b"\r\n")
359
text = text.decode('utf-8')
360
if text.lower().startswith('error: '):
361
trace.show_error('git: %s', text[len(b'error: '):])
363
trace.mutter("git: %s", text)
364
g = self._GIT_PROGRESS_PARTIAL_RE.match(text)
366
(text, pct, current, total) = g.groups()
367
self.pb.update(text, int(current), int(total))
369
g = self._GIT_PROGRESS_TOTAL_RE.match(text)
371
(text, total) = g.groups()
372
self.pb.update(text, None, int(total))
374
trace.note("%s", text)
377
class RemoteGitDir(GitDir):
379
def __init__(self, transport, format, client, client_path):
380
self._format = format
381
self.root_transport = transport
382
self.transport = transport
383
self._mode_check_done = None
384
self._client = client
385
self._client_path = client_path
386
self.base = self.root_transport.base
390
def _gitrepository_class(self):
391
return RemoteGitRepository
393
def archive(self, format, committish, write_data, progress=None, write_error=None,
394
subdirs=None, prefix=None):
395
if format not in ('tar', 'zip'):
396
raise errors.NoSuchExportFormat(format)
398
pb = ui.ui_factory.nested_progress_bar()
399
progress = DefaultProgressReporter(pb).progress
403
self._client.archive(self._client_path, committish,
404
write_data, progress, write_error, format=format,
405
subdirs=subdirs, prefix=prefix)
406
except GitProtocolError as e:
407
raise parse_git_error(self.transport.external_url(), e)
412
def fetch_pack(self, determine_wants, graph_walker, pack_data, progress=None):
414
pb = ui.ui_factory.nested_progress_bar()
415
progress = DefaultProgressReporter(pb).progress
419
result = self._client.fetch_pack(self._client_path, determine_wants,
420
graph_walker, pack_data, progress)
421
if result.refs is None:
423
self._refs = remote_refs_dict_to_container(result.refs, result.symrefs)
425
except GitProtocolError as e:
426
raise parse_git_error(self.transport.external_url(), e)
431
def send_pack(self, get_changed_refs, generate_pack_data, progress=None):
433
pb = ui.ui_factory.nested_progress_bar()
434
progress = DefaultProgressReporter(pb).progress
437
def get_changed_refs_wrapper(refs):
438
# TODO(jelmer): This drops symref information
439
self._refs = remote_refs_dict_to_container(refs)
440
return get_changed_refs(refs)
442
return self._client.send_pack(self._client_path,
443
get_changed_refs_wrapper, generate_pack_data, progress)
444
except GitProtocolError as e:
445
raise parse_git_error(self.transport.external_url(), e)
450
def create_branch(self, name=None, repository=None,
451
append_revisions_only=None, ref=None):
452
refname = self._get_selected_ref(name, ref)
453
if refname != b'HEAD' and refname in self.get_refs_container():
454
raise AlreadyBranchError(self.user_url)
455
if refname in self.get_refs_container():
456
ref_chain, unused_sha = self.get_refs_container().follow(self._get_selected_ref(None))
457
if ref_chain[0] == b'HEAD':
458
refname = ref_chain[1]
459
repo = self.open_repository()
460
return RemoteGitBranch(self, repo, refname)
462
def destroy_branch(self, name=None):
463
refname = self._get_selected_ref(name)
464
def get_changed_refs(old_refs):
466
if not refname in ret:
467
raise NotBranchError(self.user_url)
468
ret[refname] = dulwich.client.ZERO_SHA
470
def generate_pack_data(have, want, ofs_delta=False):
471
return pack_objects_to_data([])
472
self.send_pack(get_changed_refs, generate_pack_data)
476
return self.control_url
479
def user_transport(self):
480
return self.root_transport
483
def control_url(self):
484
return self.control_transport.base
487
def control_transport(self):
488
return self.root_transport
490
def open_repository(self):
491
return RemoteGitRepository(self)
493
def open_branch(self, name=None, unsupported=False,
494
ignore_fallbacks=False, ref=None, possible_transports=None,
496
repo = self.open_repository()
497
ref = self._get_selected_ref(name, ref)
498
if not nascent_ok and ref not in self.get_refs_container():
499
raise NotBranchError(self.root_transport.base,
501
ref_chain, unused_sha = self.get_refs_container().follow(ref)
502
return RemoteGitBranch(self, repo, ref_chain[-1])
504
def open_workingtree(self, recommend_upgrade=False):
505
raise NotLocalUrl(self.transport.base)
507
def has_workingtree(self):
510
def get_peeled(self, name):
511
return self.get_refs_container().get_peeled(name)
513
def get_refs_container(self):
514
if self._refs is not None:
516
result = self.fetch_pack(lambda x: None, None,
517
lambda x: None, lambda x: trace.mutter("git: %s" % x))
518
self._refs = remote_refs_dict_to_container(
519
result.refs, result.symrefs)
522
def push_branch(self, source, revision_id=None, overwrite=False,
523
remember=False, create_prefix=False, lossy=False,
525
"""Push the source branch into this ControlDir."""
526
if revision_id is None:
527
# No revision supplied by the user, default to the branch
529
revision_id = source.last_revision()
531
push_result = GitPushResult()
532
push_result.workingtree_updated = None
533
push_result.master_branch = None
534
push_result.source_branch = source
535
push_result.stacked_on = None
536
push_result.branch_push_result = None
537
repo = self.find_repository()
538
refname = self._get_selected_ref(name)
539
if isinstance(source, GitBranch) and lossy:
540
raise errors.LossyPushToSameVCS(source.controldir, self)
541
source_store = get_object_store(source.repository)
542
with source_store.lock_read():
543
def get_changed_refs(refs):
544
self._refs = remote_refs_dict_to_container(refs)
546
# TODO(jelmer): Unpeel if necessary
547
push_result.new_original_revid = revision_id
549
new_sha = source_store._lookup_revision_sha1(revision_id)
551
new_sha = repo.lookup_bzr_revision_id(revision_id)[0]
553
if remote_divergence(ret.get(refname), new_sha, source_store):
554
raise DivergedBranches(
555
source, self.open_branch(name, nascent_ok=True))
556
ret[refname] = new_sha
559
generate_pack_data = source_store.generate_lossy_pack_data
561
generate_pack_data = source_store.generate_pack_data
562
new_refs = self.send_pack(get_changed_refs, generate_pack_data)
563
push_result.new_revid = repo.lookup_foreign_revision_id(
566
old_remote = self._refs[refname]
568
old_remote = ZERO_SHA
569
push_result.old_revid = repo.lookup_foreign_revision_id(old_remote)
570
self._refs = remote_refs_dict_to_container(new_refs)
571
push_result.target_branch = self.open_branch(name)
572
if old_remote != ZERO_SHA:
573
push_result.branch_push_result = GitBranchPushResult()
574
push_result.branch_push_result.source_branch = source
575
push_result.branch_push_result.target_branch = push_result.target_branch
576
push_result.branch_push_result.local_branch = None
577
push_result.branch_push_result.master_branch = push_result.target_branch
578
push_result.branch_push_result.old_revid = push_result.old_revid
579
push_result.branch_push_result.new_revid = push_result.new_revid
580
push_result.branch_push_result.new_original_revid = push_result.new_original_revid
581
if source.get_push_location() is None or remember:
582
source.set_push_location(push_result.target_branch.base)
585
def _find_commondir(self):
586
# There is no way to find the commondir, if there is any.
590
class EmptyObjectStoreIterator(dict):
592
def iterobjects(self):
596
class TemporaryPackIterator(Pack):
598
def __init__(self, path, resolve_ext_ref):
599
super(TemporaryPackIterator, self).__init__(
600
path, resolve_ext_ref=resolve_ext_ref)
601
self._idx_load = lambda: self._idx_load_or_generate(self._idx_path)
603
def _idx_load_or_generate(self, path):
604
if not os.path.exists(path):
605
pb = ui.ui_factory.nested_progress_bar()
607
def report_progress(cur, total):
608
pb.update("generating index", cur, total)
609
self.data.create_index(path,
610
progress=report_progress)
613
return load_pack_index(path)
616
if self._idx is not None:
618
os.remove(self._idx_path)
619
if self._data is not None:
621
os.remove(self._data_path)
624
class BzrGitHttpClient(dulwich.client.HttpGitClient):
626
def __init__(self, transport, *args, **kwargs):
627
self.transport = transport
628
super(BzrGitHttpClient, self).__init__(transport.external_url(), *args, **kwargs)
630
def _http_request(self, url, headers=None, data=None,
631
allow_compression=False):
632
"""Perform HTTP request.
634
:param url: Request URL.
635
:param headers: Optional custom headers to override defaults.
636
:param data: Request data.
637
:param allow_compression: Allow GZipped communication.
638
:return: Tuple (`response`, `read`), where response is an `urllib3`
639
response object with additional `content_type` and
640
`redirect_location` properties, and `read` is a consumable read
641
method for the response data.
643
from breezy.transport.http._urllib2_wrappers import Request
644
headers['User-agent'] = user_agent_for_github()
645
headers["Pragma"] = "no-cache"
646
if allow_compression:
647
headers["Accept-Encoding"] = "gzip"
649
headers["Accept-Encoding"] = "identity"
652
('GET' if data is None else 'POST'),
654
accepted_errors=[200, 404])
655
request.follow_redirections = True
657
response = self.transport._perform(request)
659
if response.code == 404:
660
raise NotGitRepository()
661
elif response.code != 200:
662
raise GitProtocolError("unexpected http resp %d for %s" %
663
(response.code, url))
665
# TODO: Optimization available by adding `preload_content=False` to the
666
# request and just passing the `read` method on instead of going via
667
# `BytesIO`, if we can guarantee that the entire response is consumed
668
# before issuing the next to still allow for connection reuse from the
670
if response.getheader("Content-Encoding") == "gzip":
671
read = gzip.GzipFile(fileobj=response).read
675
class WrapResponse(object):
677
def __init__(self, response):
678
self._response = response
679
self.status = response.code
680
self.content_type = response.getheader("Content-Type")
681
self.redirect_location = response.geturl()
684
self._response.close()
686
return WrapResponse(response), read
689
class RemoteGitControlDirFormat(GitControlDirFormat):
690
"""The .git directory control format."""
692
supports_workingtrees = False
695
def _known_formats(self):
696
return set([RemoteGitControlDirFormat()])
698
def get_branch_format(self):
699
return RemoteGitBranchFormat()
701
def is_initializable(self):
704
def is_supported(self):
707
def open(self, transport, _found=None):
708
"""Open this directory.
711
# we dont grok readonly - git isn't integrated with transport.
713
if url.startswith('readonly+'):
714
url = url[len('readonly+'):]
715
scheme = urlparse.urlsplit(transport.external_url())[0]
716
if isinstance(transport, GitSmartTransport):
717
client = transport._get_client()
718
client_path = transport._get_path()
719
elif scheme in ("http", "https"):
720
client = BzrGitHttpClient(transport)
721
client_path, _ = urlutils.split_segment_parameters(transport._path)
722
elif scheme == 'file':
723
client = dulwich.client.LocalGitClient()
724
client_path = transport.local_abspath('.')
726
raise NotBranchError(transport.base)
728
pass # TODO(jelmer): Actually probe for something
729
return RemoteGitDir(transport, self, client, client_path)
731
def get_format_description(self):
732
return "Remote Git Repository"
734
def initialize_on_transport(self, transport):
735
raise UninitializableFormat(self)
737
def supports_transport(self, transport):
739
external_url = transport.external_url()
740
except InProcessTransport:
741
raise NotBranchError(path=transport.base)
742
return (external_url.startswith("http:") or
743
external_url.startswith("https:") or
744
external_url.startswith("git+") or
745
external_url.startswith("git:"))
748
class GitRemoteRevisionTree(RevisionTree):
750
def archive(self, format, name, root=None, subdir=None, force_mtime=None):
751
"""Create an archive of this tree.
753
:param format: Format name (e.g. 'tar')
754
:param name: target file name
755
:param root: Root directory name (or None)
756
:param subdir: Subdirectory to export (or None)
757
:return: Iterator over archive chunks
759
commit = self._repository.lookup_bzr_revision_id(
760
self.get_revision_id())[0]
761
f = tempfile.SpooledTemporaryFile()
762
# git-upload-archive(1) generaly only supports refs. So let's see if we
766
self._repository.controldir.get_refs_container().as_dict().items()}
768
committish = reverse_refs[commit]
770
# No? Maybe the user has uploadArchive.allowUnreachable enabled.
771
# Let's hope for the best.
773
self._repository.archive(
774
format, committish, f.write,
775
subdirs=([subdir] if subdir else None),
776
prefix=(root+'/') if root else '')
778
return osutils.file_iterator(f)
781
class RemoteGitRepository(GitRepository):
785
return self.control_url
787
def get_parent_map(self, revids):
788
raise GitSmartRemoteNotSupported(self.get_parent_map, self)
790
def archive(self, *args, **kwargs):
791
return self.controldir.archive(*args, **kwargs)
793
def fetch_pack(self, determine_wants, graph_walker, pack_data,
795
return self.controldir.fetch_pack(determine_wants, graph_walker,
798
def send_pack(self, get_changed_refs, generate_pack_data):
799
return self.controldir.send_pack(get_changed_refs, generate_pack_data)
801
def fetch_objects(self, determine_wants, graph_walker, resolve_ext_ref,
803
fd, path = tempfile.mkstemp(suffix=".pack")
805
self.fetch_pack(determine_wants, graph_walker,
806
lambda x: os.write(fd, x), progress)
809
if os.path.getsize(path) == 0:
810
return EmptyObjectStoreIterator()
811
return TemporaryPackIterator(path[:-len(".pack")], resolve_ext_ref)
813
def lookup_bzr_revision_id(self, bzr_revid, mapping=None):
814
# This won't work for any round-tripped bzr revisions, but it's a start..
816
return mapping_registry.revision_id_bzr_to_foreign(bzr_revid)
817
except InvalidRevisionId:
818
raise NoSuchRevision(self, bzr_revid)
820
def lookup_foreign_revision_id(self, foreign_revid, mapping=None):
821
"""Lookup a revision id.
825
mapping = self.get_mapping()
826
# Not really an easy way to parse foreign revids here..
827
return mapping.revision_id_foreign_to_bzr(foreign_revid)
829
def revision_tree(self, revid):
830
return GitRemoteRevisionTree(self, revid)
832
def get_revisions(self, revids):
833
raise GitSmartRemoteNotSupported(self.get_revisions, self)
835
def has_revisions(self, revids):
836
raise GitSmartRemoteNotSupported(self.get_revisions, self)
839
class RemoteGitTagDict(GitTags):
841
def set_tag(self, name, revid):
842
sha = self.branch.lookup_bzr_revision_id(revid)[0]
843
self._set_ref(name, sha)
845
def delete_tag(self, name):
846
self._set_ref(name, dulwich.client.ZERO_SHA)
848
def _set_ref(self, name, sha):
849
ref = tag_name_to_ref(name)
850
def get_changed_refs(old_refs):
852
if sha == dulwich.client.ZERO_SHA and ref not in ret:
853
raise NoSuchTag(name)
856
def generate_pack_data(have, want, ofs_delta=False):
857
return pack_objects_to_data([])
858
self.repository.send_pack(get_changed_refs, generate_pack_data)
861
class RemoteGitBranch(GitBranch):
863
def __init__(self, controldir, repository, name):
865
super(RemoteGitBranch, self).__init__(controldir, repository, name,
866
RemoteGitBranchFormat())
868
def last_revision_info(self):
869
raise GitSmartRemoteNotSupported(self.last_revision_info, self)
873
return self.control_url
876
def control_url(self):
879
def revision_id_to_revno(self, revision_id):
880
raise GitSmartRemoteNotSupported(self.revision_id_to_revno, self)
882
def last_revision(self):
883
return self.lookup_foreign_revision_id(self.head)
887
if self._sha is not None:
889
refs = self.controldir.get_refs_container()
890
name = branch_name_to_ref(self.name)
892
self._sha = refs[name]
894
raise NoSuchRef(name, self.repository.user_url, refs)
897
def _synchronize_history(self, destination, revision_id):
898
"""See Branch._synchronize_history()."""
899
destination.generate_revision_history(self.last_revision())
901
def _get_parent_location(self):
904
def get_push_location(self):
907
def set_push_location(self, url):
910
def _iter_tag_refs(self):
911
"""Iterate over the tag refs.
913
:param refs: Refs dictionary (name -> git sha1)
914
:return: iterator over (ref_name, tag_name, peeled_sha1, unpeeled_sha1)
916
refs = self.controldir.get_refs_container()
917
for ref_name, unpeeled in refs.as_dict().items():
919
tag_name = ref_to_tag_name(ref_name)
920
except (ValueError, UnicodeDecodeError):
922
peeled = refs.get_peeled(ref_name)
924
# Let's just hope it's a commit
926
if not isinstance(tag_name, text_type):
927
raise TypeError(tag_name)
928
yield (ref_name, tag_name, peeled, unpeeled)
931
def remote_refs_dict_to_container(refs_dict, symrefs_dict={}):
934
for k, v in refs_dict.items():
939
for name, target in symrefs_dict.items():
940
base[name] = SYMREF + target
941
ret = DictRefsContainer(base)