1
# Copyright (C) 2005-2010 Canonical Ltd
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
"""Read in a bundle stream, and process it into a BundleReader object."""
19
from __future__ import absolute_import
22
from io import BytesIO
31
from . import apply_bundle
32
from ...errors import (
37
from ..inventory import (
43
from ...osutils import sha_string, pathjoin
44
from ...revision import Revision, NULL_REVISION
45
from ...sixish import (
48
from ..testament import StrictTestament
49
from ...trace import mutter, warning
54
from ..xml5 import serializer_v5
57
class RevisionInfo(object):
58
"""Gets filled out for each revision object that is read.
61
def __init__(self, revision_id):
62
self.revision_id = revision_id
68
self.inventory_sha1 = None
70
self.parent_ids = None
73
self.properties = None
74
self.tree_actions = None
77
return pprint.pformat(self.__dict__)
79
def as_revision(self):
80
rev = Revision(revision_id=self.revision_id,
81
committer=self.committer,
82
timestamp=float(self.timestamp),
83
timezone=int(self.timezone),
84
inventory_sha1=self.inventory_sha1,
85
message='\n'.join(self.message))
88
rev.parent_ids.extend(self.parent_ids)
91
for property in self.properties:
92
key_end = property.find(': ')
94
if not property.endswith(':'):
95
raise ValueError(property)
96
key = str(property[:-1])
99
key = str(property[:key_end])
100
value = property[key_end + 2:]
101
rev.properties[key] = value
106
def from_revision(revision):
107
revision_info = RevisionInfo(revision.revision_id)
108
date = timestamp.format_highres_date(revision.timestamp,
110
revision_info.date = date
111
revision_info.timezone = revision.timezone
112
revision_info.timestamp = revision.timestamp
113
revision_info.message = revision.message.split('\n')
114
revision_info.properties = [': '.join(p) for p in
115
viewitems(revision.properties)]
119
class BundleInfo(object):
120
"""This contains the meta information. Stuff that allows you to
121
recreate the revision or inventory XML.
124
def __init__(self, bundle_format=None):
125
self.bundle_format = None
126
self.committer = None
130
# A list of RevisionInfo objects
133
# The next entries are created during complete_info() and
134
# other post-read functions.
136
# A list of real Revision objects
137
self.real_revisions = []
139
self.timestamp = None
142
# Have we checked the repository yet?
143
self._validated_revisions_against_repo = False
146
return pprint.pformat(self.__dict__)
148
def complete_info(self):
149
"""This makes sure that all information is properly
150
split up, based on the assumptions that can be made
151
when information is missing.
153
from breezy.timestamp import unpack_highres_date
154
# Put in all of the guessable information.
155
if not self.timestamp and self.date:
156
self.timestamp, self.timezone = unpack_highres_date(self.date)
158
self.real_revisions = []
159
for rev in self.revisions:
160
if rev.timestamp is None:
161
if rev.date is not None:
162
rev.timestamp, rev.timezone = \
163
unpack_highres_date(rev.date)
165
rev.timestamp = self.timestamp
166
rev.timezone = self.timezone
167
if rev.message is None and self.message:
168
rev.message = self.message
169
if rev.committer is None and self.committer:
170
rev.committer = self.committer
171
self.real_revisions.append(rev.as_revision())
173
def get_base(self, revision):
174
revision_info = self.get_revision_info(revision.revision_id)
175
if revision_info.base_id is not None:
176
return revision_info.base_id
177
if len(revision.parent_ids) == 0:
178
# There is no base listed, and
179
# the lowest revision doesn't have a parent
180
# so this is probably against the empty tree
181
# and thus base truly is NULL_REVISION
184
return revision.parent_ids[-1]
186
def _get_target(self):
187
"""Return the target revision."""
188
if len(self.real_revisions) > 0:
189
return self.real_revisions[0].revision_id
190
elif len(self.revisions) > 0:
191
return self.revisions[0].revision_id
194
target = property(_get_target, doc='The target revision id')
196
def get_revision(self, revision_id):
197
for r in self.real_revisions:
198
if r.revision_id == revision_id:
200
raise KeyError(revision_id)
202
def get_revision_info(self, revision_id):
203
for r in self.revisions:
204
if r.revision_id == revision_id:
206
raise KeyError(revision_id)
208
def revision_tree(self, repository, revision_id, base=None):
209
revision = self.get_revision(revision_id)
210
base = self.get_base(revision)
211
if base == revision_id:
212
raise AssertionError()
213
if not self._validated_revisions_against_repo:
214
self._validate_references_from_repository(repository)
215
revision_info = self.get_revision_info(revision_id)
216
inventory_revision_id = revision_id
217
bundle_tree = BundleTree(repository.revision_tree(base),
218
inventory_revision_id)
219
self._update_tree(bundle_tree, revision_id)
221
inv = bundle_tree.inventory
222
self._validate_inventory(inv, revision_id)
223
self._validate_revision(bundle_tree, revision_id)
227
def _validate_references_from_repository(self, repository):
228
"""Now that we have a repository which should have some of the
229
revisions we care about, go through and validate all of them
235
def add_sha(d, revision_id, sha1):
236
if revision_id is None:
238
raise BzrError('A Null revision should always'
239
'have a null sha1 hash')
242
# This really should have been validated as part
243
# of _validate_revisions but lets do it again
244
if sha1 != d[revision_id]:
245
raise BzrError('** Revision %r referenced with 2 different'
246
' sha hashes %s != %s' % (revision_id,
247
sha1, d[revision_id]))
249
d[revision_id] = sha1
251
# All of the contained revisions were checked
252
# in _validate_revisions
254
for rev_info in self.revisions:
255
checked[rev_info.revision_id] = True
256
add_sha(rev_to_sha, rev_info.revision_id, rev_info.sha1)
258
for (rev, rev_info) in zip(self.real_revisions, self.revisions):
259
add_sha(inv_to_sha, rev_info.revision_id, rev_info.inventory_sha1)
263
for revision_id, sha1 in viewitems(rev_to_sha):
264
if repository.has_revision(revision_id):
265
testament = StrictTestament.from_revision(repository,
267
local_sha1 = self._testament_sha1_from_revision(repository,
269
if sha1 != local_sha1:
270
raise BzrError('sha1 mismatch. For revision id {%s}'
271
'local: %s, bundle: %s' % (revision_id, local_sha1, sha1))
274
elif revision_id not in checked:
275
missing[revision_id] = sha1
278
# I don't know if this is an error yet
279
warning('Not all revision hashes could be validated.'
280
' Unable validate %d hashes' % len(missing))
281
mutter('Verified %d sha hashes for the bundle.' % count)
282
self._validated_revisions_against_repo = True
284
def _validate_inventory(self, inv, revision_id):
285
"""At this point we should have generated the BundleTree,
286
so build up an inventory, and make sure the hashes match.
288
# Now we should have a complete inventory entry.
289
s = serializer_v5.write_inventory_to_string(inv)
291
# Target revision is the last entry in the real_revisions list
292
rev = self.get_revision(revision_id)
293
if rev.revision_id != revision_id:
294
raise AssertionError()
295
if sha1 != rev.inventory_sha1:
296
with open(',,bogus-inv', 'wb') as f:
298
warning('Inventory sha hash mismatch for revision %s. %s'
299
' != %s' % (revision_id, sha1, rev.inventory_sha1))
301
def _testament(self, revision, tree):
302
raise NotImplementedError(self._testament)
304
def _validate_revision(self, tree, revision_id):
305
"""Make sure all revision entries match their checksum."""
307
# This is a mapping from each revision id to its sha hash
310
rev = self.get_revision(revision_id)
311
rev_info = self.get_revision_info(revision_id)
312
if not (rev.revision_id == rev_info.revision_id):
313
raise AssertionError()
314
if not (rev.revision_id == revision_id):
315
raise AssertionError()
316
testament = self._testament(rev, tree)
317
sha1 = testament.as_sha1()
318
if sha1 != rev_info.sha1:
319
raise TestamentMismatch(rev.revision_id, rev_info.sha1, sha1)
320
if rev.revision_id in rev_to_sha1:
321
raise BzrError('Revision {%s} given twice in the list'
323
rev_to_sha1[rev.revision_id] = sha1
325
def _update_tree(self, bundle_tree, revision_id):
326
"""This fills out a BundleTree based on the information
329
:param bundle_tree: A BundleTree to update with the new information.
332
def get_rev_id(last_changed, path, kind):
333
if last_changed is not None:
334
# last_changed will be a Unicode string because of how it was
335
# read. Convert it back to utf8.
336
changed_revision_id = cache_utf8.encode(last_changed)
338
changed_revision_id = revision_id
339
bundle_tree.note_last_changed(path, changed_revision_id)
340
return changed_revision_id
342
def extra_info(info, new_path):
345
for info_item in info:
347
name, value = info_item.split(':', 1)
349
raise ValueError('Value %r has no colon' % info_item)
350
if name == 'last-changed':
352
elif name == 'executable':
353
val = (value == 'yes')
354
bundle_tree.note_executable(new_path, val)
355
elif name == 'target':
356
bundle_tree.note_target(new_path, value)
357
elif name == 'encoding':
359
return last_changed, encoding
361
def do_patch(path, lines, encoding):
362
if encoding == 'base64':
363
patch = base64.b64decode(b''.join(lines))
364
elif encoding is None:
365
patch = b''.join(lines)
367
raise ValueError(encoding)
368
bundle_tree.note_patch(path, patch)
370
def renamed(kind, extra, lines):
371
info = extra.split(' // ')
373
raise BzrError('renamed action lines need both a from and to'
376
if info[1].startswith('=> '):
377
new_path = info[1][3:]
381
bundle_tree.note_rename(old_path, new_path)
382
last_modified, encoding = extra_info(info[2:], new_path)
383
revision = get_rev_id(last_modified, new_path, kind)
385
do_patch(new_path, lines, encoding)
387
def removed(kind, extra, lines):
388
info = extra.split(' // ')
390
# TODO: in the future we might allow file ids to be
391
# given for removed entries
392
raise BzrError('removed action lines should only have the path'
395
bundle_tree.note_deletion(path)
397
def added(kind, extra, lines):
398
info = extra.split(' // ')
400
raise BzrError('add action lines require the path and file id'
403
raise BzrError('add action lines have fewer than 5 entries.'
406
if not info[1].startswith('file-id:'):
407
raise BzrError('The file-id should follow the path for an add'
409
# This will be Unicode because of how the stream is read. Turn it
410
# back into a utf8 file_id
411
file_id = cache_utf8.encode(info[1][8:])
413
bundle_tree.note_id(file_id, path, kind)
414
# this will be overridden in extra_info if executable is specified.
415
bundle_tree.note_executable(path, False)
416
last_changed, encoding = extra_info(info[2:], path)
417
revision = get_rev_id(last_changed, path, kind)
418
if kind == 'directory':
420
do_patch(path, lines, encoding)
422
def modified(kind, extra, lines):
423
info = extra.split(' // ')
425
raise BzrError('modified action lines have at least'
426
'the path in them: %r' % extra)
429
last_modified, encoding = extra_info(info[1:], path)
430
revision = get_rev_id(last_modified, path, kind)
432
do_patch(path, lines, encoding)
440
for action_line, lines in \
441
self.get_revision_info(revision_id).tree_actions:
442
first = action_line.find(' ')
444
raise BzrError('Bogus action line'
445
' (no opening space): %r' % action_line)
446
second = action_line.find(' ', first + 1)
448
raise BzrError('Bogus action line'
449
' (missing second space): %r' % action_line)
450
action = action_line[:first]
451
kind = action_line[first + 1:second]
452
if kind not in ('file', 'directory', 'symlink'):
453
raise BzrError('Bogus action line'
454
' (invalid object kind %r): %r' % (kind, action_line))
455
extra = action_line[second + 1:]
457
if action not in valid_actions:
458
raise BzrError('Bogus action line'
459
' (unrecognized action): %r' % action_line)
460
valid_actions[action](kind, extra, lines)
462
def install_revisions(self, target_repo, stream_input=True):
463
"""Install revisions and return the target revision
465
:param target_repo: The repository to install into
466
:param stream_input: Ignored by this implementation.
468
apply_bundle.install_bundle(target_repo, self)
471
def get_merge_request(self, target_repo):
472
"""Provide data for performing a merge
474
Returns suggested base, suggested target, and patch verification status
476
return None, self.target, 'inapplicable'
479
class BundleTree(Tree):
481
def __init__(self, base_tree, revision_id):
482
self.base_tree = base_tree
483
self._renamed = {} # Mapping from old_path => new_path
484
self._renamed_r = {} # new_path => old_path
485
self._new_id = {} # new_path => new_id
486
self._new_id_r = {} # new_id => new_path
487
self._kinds = {} # new_path => kind
488
self._last_changed = {} # new_id => revision_id
489
self._executable = {} # new_id => executable value
491
self._targets = {} # new path => new symlink target
493
self.revision_id = revision_id
494
self._inventory = None
497
return pprint.pformat(self.__dict__)
499
def note_rename(self, old_path, new_path):
500
"""A file/directory has been renamed from old_path => new_path"""
501
if new_path in self._renamed:
502
raise AssertionError(new_path)
503
if old_path in self._renamed_r:
504
raise AssertionError(old_path)
505
self._renamed[new_path] = old_path
506
self._renamed_r[old_path] = new_path
508
def note_id(self, new_id, new_path, kind='file'):
509
"""Files that don't exist in base need a new id."""
510
self._new_id[new_path] = new_id
511
self._new_id_r[new_id] = new_path
512
self._kinds[new_path] = kind
514
def note_last_changed(self, file_id, revision_id):
515
if (file_id in self._last_changed
516
and self._last_changed[file_id] != revision_id):
517
raise BzrError('Mismatched last-changed revision for file_id {%s}'
518
': %s != %s' % (file_id,
519
self._last_changed[file_id],
521
self._last_changed[file_id] = revision_id
523
def note_patch(self, new_path, patch):
524
"""There is a patch for a given filename."""
525
self.patches[new_path] = patch
527
def note_target(self, new_path, target):
528
"""The symlink at the new path has the given target"""
529
self._targets[new_path] = target
531
def note_deletion(self, old_path):
532
"""The file at old_path has been deleted."""
533
self.deleted.append(old_path)
535
def note_executable(self, new_path, executable):
536
self._executable[new_path] = executable
538
def old_path(self, new_path):
539
"""Get the old_path (path in the base_tree) for the file at new_path"""
540
if new_path[:1] in ('\\', '/'):
541
raise ValueError(new_path)
542
old_path = self._renamed.get(new_path)
543
if old_path is not None:
545
dirname, basename = os.path.split(new_path)
546
# dirname is not '' doesn't work, because
547
# dirname may be a unicode entry, and is
548
# requires the objects to be identical
550
old_dir = self.old_path(dirname)
554
old_path = pathjoin(old_dir, basename)
557
# If the new path wasn't in renamed, the old one shouldn't be in
559
if old_path in self._renamed_r:
563
def new_path(self, old_path):
564
"""Get the new_path (path in the target_tree) for the file at old_path
567
if old_path[:1] in ('\\', '/'):
568
raise ValueError(old_path)
569
new_path = self._renamed_r.get(old_path)
570
if new_path is not None:
572
if new_path in self._renamed:
574
dirname, basename = os.path.split(old_path)
576
new_dir = self.new_path(dirname)
580
new_path = pathjoin(new_dir, basename)
583
# If the old path wasn't in renamed, the new one shouldn't be in
585
if new_path in self._renamed:
589
def path2id(self, path):
590
"""Return the id of the file present at path in the target tree."""
591
file_id = self._new_id.get(path)
592
if file_id is not None:
594
old_path = self.old_path(path)
597
if old_path in self.deleted:
599
return self.base_tree.path2id(old_path)
601
def id2path(self, file_id, recurse='down'):
602
"""Return the new path in the target tree of the file with id file_id"""
603
path = self._new_id_r.get(file_id)
606
old_path = self.base_tree.id2path(file_id, recurse)
608
raise NoSuchId(file_id, self)
609
if old_path in self.deleted:
610
raise NoSuchId(file_id, self)
611
new_path = self.new_path(old_path)
613
raise NoSuchId(file_id, self)
616
def get_file(self, path):
617
"""Return a file-like object containing the new contents of the
618
file given by file_id.
620
TODO: It might be nice if this actually generated an entry
621
in the text-store, so that the file contents would
624
old_path = find_previous_path(self, self.base_tree, path)
626
patch_original = None
628
patch_original = self.base_tree.get_file(old_path)
629
file_patch = self.patches.get(path)
630
if file_patch is None:
631
if (patch_original is None and
632
self.kind(path) == 'directory'):
634
if patch_original is None:
635
raise AssertionError("None: %s" % file_id)
636
return patch_original
638
if file_patch.startswith(b'\\'):
640
'Malformed patch for %s, %r' % (file_id, file_patch))
641
return patched_file(file_patch, patch_original)
643
def get_symlink_target(self, path):
645
return self._targets[path]
647
old_path = self.old_path(path)
648
return self.base_tree.get_symlink_target(old_path)
650
def kind(self, path):
652
return self._kinds[path]
654
old_path = self.old_path(path)
655
return self.base_tree.kind(old_path)
657
def get_file_revision(self, path):
658
if path in self._last_changed:
659
return self._last_changed[path]
661
old_path = self.old_path(path)
662
return self.base_tree.get_file_revision(old_path)
664
def is_executable(self, path):
665
if path in self._executable:
666
return self._executable[path]
668
old_path = self.old_path(path)
669
return self.base_tree.is_executable(old_path)
671
def get_last_changed(self, path):
672
if path in self._last_changed:
673
return self._last_changed[path]
674
old_path = self.old_path(path)
675
return self.base_tree.get_file_revision(old_path)
677
def get_size_and_sha1(self, new_path):
678
"""Return the size and sha1 hash of the given file id.
679
If the file was not locally modified, this is extracted
680
from the base_tree. Rather than re-reading the file.
684
if new_path not in self.patches:
685
# If the entry does not have a patch, then the
686
# contents must be the same as in the base_tree
687
base_path = self.old_path(new_path)
688
text_size = self.base_tree.get_file_size(base_path)
689
text_sha1 = self.base_tree.get_file_sha1(base_path)
690
return text_size, text_sha1
691
fileobj = self.get_file(new_path)
692
content = fileobj.read()
693
return len(content), sha_string(content)
695
def _get_inventory(self):
696
"""Build up the inventory entry for the BundleTree.
698
This need to be called before ever accessing self.inventory
700
from os.path import dirname, basename
701
inv = Inventory(None, self.revision_id)
703
def add_entry(path, file_id):
707
parent_path = dirname(path)
708
parent_id = self.path2id(parent_path)
710
kind = self.kind(path)
711
revision_id = self.get_last_changed(path)
713
name = basename(path)
714
if kind == 'directory':
715
ie = InventoryDirectory(file_id, name, parent_id)
717
ie = InventoryFile(file_id, name, parent_id)
718
ie.executable = self.is_executable(path)
719
elif kind == 'symlink':
720
ie = InventoryLink(file_id, name, parent_id)
721
ie.symlink_target = self.get_symlink_target(path)
722
ie.revision = revision_id
725
ie.text_size, ie.text_sha1 = self.get_size_and_sha1(path)
726
if ie.text_size is None:
728
'Got a text_size of None for file_id %r' % file_id)
731
sorted_entries = self.sorted_path_id()
732
for path, file_id in sorted_entries:
733
add_entry(path, file_id)
737
# Have to overload the inherited inventory property
738
# because _get_inventory is only called in the parent.
739
# Reading the docs, property() objects do not use
740
# overloading, they use the function as it was defined
742
inventory = property(_get_inventory)
744
root_inventory = property(_get_inventory)
746
def all_file_ids(self):
747
return {entry.file_id for path, entry in self.inventory.iter_entries()}
749
def all_versioned_paths(self):
750
return {path for path, entry in self.inventory.iter_entries()}
752
def list_files(self, include_root=False, from_dir=None, recursive=True):
753
# The only files returned by this are those from the version
758
from_dir_id = inv.path2id(from_dir)
759
if from_dir_id is None:
760
# Directory not versioned
762
entries = inv.iter_entries(from_dir=from_dir_id, recursive=recursive)
763
if inv.root is not None and not include_root and from_dir is None:
764
# skip the root for compatibility with the current apis.
766
for path, entry in entries:
767
yield path, 'V', entry.kind, entry
769
def sorted_path_id(self):
771
for result in viewitems(self._new_id):
773
for id in self.base_tree.all_file_ids():
775
path = self.id2path(id, recurse='none')
778
paths.append((path, id))
783
def patched_file(file_patch, original):
784
"""Produce a file-like object with the patched version of a text"""
785
from breezy.patches import iter_patched
786
from breezy.iterablefile import IterableFile
787
if file_patch == b"":
788
return IterableFile(())
789
# string.splitlines(True) also splits on '\r', but the iter_patched code
790
# only expects to iterate over '\n' style lines
791
return IterableFile(iter_patched(original,
792
BytesIO(file_patch).readlines()))