~bzr-pqm/bzr/bzr.dev

« back to all changes in this revision

Viewing changes to bzrlib/bundle/read_bundle.py

  • Committer: Canonical.com Patch Queue Manager
  • Date: 2006-06-03 20:18:35 UTC
  • mfrom: (1185.82.137 w-changeset)
  • Revision ID: pqm@pqm.ubuntu.com-20060603201835-1c9a1725641ccd24
Implement bundles

Show diffs side-by-side

added added

removed removed

Lines of Context:
 
1
#!/usr/bin/env python
 
2
"""\
 
3
Read in a bundle stream, and process it into a BundleReader object.
 
4
"""
 
5
 
 
6
import base64
 
7
from cStringIO import StringIO
 
8
import os
 
9
import pprint
 
10
 
 
11
from bzrlib.errors import (TestamentMismatch, BzrError, 
 
12
                           MalformedHeader, MalformedPatches)
 
13
from bzrlib.bundle.common import get_header, header_str
 
14
from bzrlib.inventory import (Inventory, InventoryEntry,
 
15
                              InventoryDirectory, InventoryFile,
 
16
                              InventoryLink)
 
17
from bzrlib.osutils import sha_file, sha_string
 
18
from bzrlib.revision import Revision, NULL_REVISION
 
19
from bzrlib.testament import StrictTestament
 
20
from bzrlib.trace import mutter, warning
 
21
from bzrlib.tree import Tree
 
22
from bzrlib.xml5 import serializer_v5
 
23
 
 
24
 
 
25
class RevisionInfo(object):
 
26
    """Gets filled out for each revision object that is read.
 
27
    """
 
28
    def __init__(self, revision_id):
 
29
        self.revision_id = revision_id
 
30
        self.sha1 = None
 
31
        self.committer = None
 
32
        self.date = None
 
33
        self.timestamp = None
 
34
        self.timezone = None
 
35
        self.inventory_sha1 = None
 
36
 
 
37
        self.parent_ids = None
 
38
        self.base_id = None
 
39
        self.message = None
 
40
        self.properties = None
 
41
        self.tree_actions = None
 
42
 
 
43
    def __str__(self):
 
44
        return pprint.pformat(self.__dict__)
 
45
 
 
46
    def as_revision(self):
 
47
        rev = Revision(revision_id=self.revision_id,
 
48
            committer=self.committer,
 
49
            timestamp=float(self.timestamp),
 
50
            timezone=int(self.timezone),
 
51
            inventory_sha1=self.inventory_sha1,
 
52
            message='\n'.join(self.message))
 
53
 
 
54
        if self.parent_ids:
 
55
            rev.parent_ids.extend(self.parent_ids)
 
56
 
 
57
        if self.properties:
 
58
            for property in self.properties:
 
59
                key_end = property.find(': ')
 
60
                assert key_end is not None
 
61
                key = property[:key_end].encode('utf-8')
 
62
                value = property[key_end+2:].encode('utf-8')
 
63
                rev.properties[key] = value
 
64
 
 
65
        return rev
 
66
 
 
67
 
 
68
class BundleInfo(object):
 
69
    """This contains the meta information. Stuff that allows you to
 
70
    recreate the revision or inventory XML.
 
71
    """
 
72
    def __init__(self):
 
73
        self.committer = None
 
74
        self.date = None
 
75
        self.message = None
 
76
 
 
77
        # A list of RevisionInfo objects
 
78
        self.revisions = []
 
79
 
 
80
        # The next entries are created during complete_info() and
 
81
        # other post-read functions.
 
82
 
 
83
        # A list of real Revision objects
 
84
        self.real_revisions = []
 
85
 
 
86
        self.timestamp = None
 
87
        self.timezone = None
 
88
 
 
89
    def __str__(self):
 
90
        return pprint.pformat(self.__dict__)
 
91
 
 
92
    def complete_info(self):
 
93
        """This makes sure that all information is properly
 
94
        split up, based on the assumptions that can be made
 
95
        when information is missing.
 
96
        """
 
97
        from bzrlib.bundle.common import unpack_highres_date
 
98
        # Put in all of the guessable information.
 
99
        if not self.timestamp and self.date:
 
100
            self.timestamp, self.timezone = unpack_highres_date(self.date)
 
101
 
 
102
        self.real_revisions = []
 
103
        for rev in self.revisions:
 
104
            if rev.timestamp is None:
 
105
                if rev.date is not None:
 
106
                    rev.timestamp, rev.timezone = \
 
107
                            unpack_highres_date(rev.date)
 
108
                else:
 
109
                    rev.timestamp = self.timestamp
 
110
                    rev.timezone = self.timezone
 
111
            if rev.message is None and self.message:
 
112
                rev.message = self.message
 
113
            if rev.committer is None and self.committer:
 
114
                rev.committer = self.committer
 
115
            self.real_revisions.append(rev.as_revision())
 
116
 
 
117
    def get_base(self, revision):
 
118
        revision_info = self.get_revision_info(revision.revision_id)
 
119
        if revision_info.base_id is not None:
 
120
            if revision_info.base_id == NULL_REVISION:
 
121
                return None
 
122
            else:
 
123
                return revision_info.base_id
 
124
        if len(revision.parent_ids) == 0:
 
125
            # There is no base listed, and
 
126
            # the lowest revision doesn't have a parent
 
127
            # so this is probably against the empty tree
 
128
            # and thus base truly is None
 
129
            return None
 
130
        else:
 
131
            return revision.parent_ids[-1]
 
132
 
 
133
    def _get_target(self):
 
134
        """Return the target revision."""
 
135
        if len(self.real_revisions) > 0:
 
136
            return self.real_revisions[0].revision_id
 
137
        elif len(self.revisions) > 0:
 
138
            return self.revisions[0].revision_id
 
139
        return None
 
140
 
 
141
    target = property(_get_target, doc='The target revision id')
 
142
 
 
143
    def get_revision(self, revision_id):
 
144
        for r in self.real_revisions:
 
145
            if r.revision_id == revision_id:
 
146
                return r
 
147
        raise KeyError(revision_id)
 
148
 
 
149
    def get_revision_info(self, revision_id):
 
150
        for r in self.revisions:
 
151
            if r.revision_id == revision_id:
 
152
                return r
 
153
        raise KeyError(revision_id)
 
154
 
 
155
 
 
156
class BundleReader(object):
 
157
    """This class reads in a bundle from a file, and returns
 
158
    a Bundle object, which can then be applied against a tree.
 
159
    """
 
160
    def __init__(self, from_file):
 
161
        """Read in the bundle from the file.
 
162
 
 
163
        :param from_file: A file-like object (must have iterator support).
 
164
        """
 
165
        object.__init__(self)
 
166
        self.from_file = iter(from_file)
 
167
        self._next_line = None
 
168
        
 
169
        self.info = BundleInfo()
 
170
        # We put the actual inventory ids in the footer, so that the patch
 
171
        # is easier to read for humans.
 
172
        # Unfortunately, that means we need to read everything before we
 
173
        # can create a proper bundle.
 
174
        self._read()
 
175
        self._validate()
 
176
 
 
177
    def _read(self):
 
178
        self._read_header()
 
179
        while self._next_line is not None:
 
180
            self._read_revision_header()
 
181
            if self._next_line is None:
 
182
                break
 
183
            self._read_patches()
 
184
            self._read_footer()
 
185
 
 
186
    def _validate(self):
 
187
        """Make sure that the information read in makes sense
 
188
        and passes appropriate checksums.
 
189
        """
 
190
        # Fill in all the missing blanks for the revisions
 
191
        # and generate the real_revisions list.
 
192
        self.info.complete_info()
 
193
 
 
194
    def _validate_revision(self, inventory, revision_id):
 
195
        """Make sure all revision entries match their checksum."""
 
196
 
 
197
        # This is a mapping from each revision id to it's sha hash
 
198
        rev_to_sha1 = {}
 
199
        
 
200
        rev = self.info.get_revision(revision_id)
 
201
        rev_info = self.info.get_revision_info(revision_id)
 
202
        assert rev.revision_id == rev_info.revision_id
 
203
        assert rev.revision_id == revision_id
 
204
        sha1 = StrictTestament(rev, inventory).as_sha1()
 
205
        if sha1 != rev_info.sha1:
 
206
            raise TestamentMismatch(rev.revision_id, rev_info.sha1, sha1)
 
207
        if rev_to_sha1.has_key(rev.revision_id):
 
208
            raise BzrError('Revision {%s} given twice in the list'
 
209
                    % (rev.revision_id))
 
210
        rev_to_sha1[rev.revision_id] = sha1
 
211
 
 
212
    def _validate_references_from_repository(self, repository):
 
213
        """Now that we have a repository which should have some of the
 
214
        revisions we care about, go through and validate all of them
 
215
        that we can.
 
216
        """
 
217
        rev_to_sha = {}
 
218
        inv_to_sha = {}
 
219
        def add_sha(d, revision_id, sha1):
 
220
            if revision_id is None:
 
221
                if sha1 is not None:
 
222
                    raise BzrError('A Null revision should always'
 
223
                        'have a null sha1 hash')
 
224
                return
 
225
            if revision_id in d:
 
226
                # This really should have been validated as part
 
227
                # of _validate_revisions but lets do it again
 
228
                if sha1 != d[revision_id]:
 
229
                    raise BzrError('** Revision %r referenced with 2 different'
 
230
                            ' sha hashes %s != %s' % (revision_id,
 
231
                                sha1, d[revision_id]))
 
232
            else:
 
233
                d[revision_id] = sha1
 
234
 
 
235
        # All of the contained revisions were checked
 
236
        # in _validate_revisions
 
237
        checked = {}
 
238
        for rev_info in self.info.revisions:
 
239
            checked[rev_info.revision_id] = True
 
240
            add_sha(rev_to_sha, rev_info.revision_id, rev_info.sha1)
 
241
                
 
242
        for (rev, rev_info) in zip(self.info.real_revisions, self.info.revisions):
 
243
            add_sha(inv_to_sha, rev_info.revision_id, rev_info.inventory_sha1)
 
244
 
 
245
        count = 0
 
246
        missing = {}
 
247
        for revision_id, sha1 in rev_to_sha.iteritems():
 
248
            if repository.has_revision(revision_id):
 
249
                testament = StrictTestament.from_revision(repository, 
 
250
                                                          revision_id)
 
251
                local_sha1 = testament.as_sha1()
 
252
                if sha1 != local_sha1:
 
253
                    raise BzrError('sha1 mismatch. For revision id {%s}' 
 
254
                            'local: %s, bundle: %s' % (revision_id, local_sha1, sha1))
 
255
                else:
 
256
                    count += 1
 
257
            elif revision_id not in checked:
 
258
                missing[revision_id] = sha1
 
259
 
 
260
        for inv_id, sha1 in inv_to_sha.iteritems():
 
261
            if repository.has_revision(inv_id):
 
262
                # Note: branch.get_inventory_sha1() just returns the value that
 
263
                # is stored in the revision text, and that value may be out
 
264
                # of date. This is bogus, because that means we aren't
 
265
                # validating the actual text, just that we wrote and read the
 
266
                # string. But for now, what the hell.
 
267
                local_sha1 = repository.get_inventory_sha1(inv_id)
 
268
                if sha1 != local_sha1:
 
269
                    raise BzrError('sha1 mismatch. For inventory id {%s}' 
 
270
                                   'local: %s, bundle: %s' % 
 
271
                                   (inv_id, local_sha1, sha1))
 
272
                else:
 
273
                    count += 1
 
274
 
 
275
        if len(missing) > 0:
 
276
            # I don't know if this is an error yet
 
277
            warning('Not all revision hashes could be validated.'
 
278
                    ' Unable validate %d hashes' % len(missing))
 
279
        mutter('Verified %d sha hashes for the bundle.' % count)
 
280
 
 
281
    def _validate_inventory(self, inv, revision_id):
 
282
        """At this point we should have generated the BundleTree,
 
283
        so build up an inventory, and make sure the hashes match.
 
284
        """
 
285
 
 
286
        assert inv is not None
 
287
 
 
288
        # Now we should have a complete inventory entry.
 
289
        s = serializer_v5.write_inventory_to_string(inv)
 
290
        sha1 = sha_string(s)
 
291
        # Target revision is the last entry in the real_revisions list
 
292
        rev = self.info.get_revision(revision_id)
 
293
        assert rev.revision_id == revision_id
 
294
        if sha1 != rev.inventory_sha1:
 
295
            open(',,bogus-inv', 'wb').write(s)
 
296
            warning('Inventory sha hash mismatch for revision %s. %s'
 
297
                    ' != %s' % (revision_id, sha1, rev.inventory_sha1))
 
298
 
 
299
    def get_bundle(self, repository):
 
300
        """Return the meta information, and a Bundle tree which can
 
301
        be used to populate the local stores and working tree, respectively.
 
302
        """
 
303
        return self.info, self.revision_tree(repository, self.info.target)
 
304
 
 
305
    def revision_tree(self, repository, revision_id, base=None):
 
306
        revision = self.info.get_revision(revision_id)
 
307
        base = self.info.get_base(revision)
 
308
        assert base != revision_id
 
309
        self._validate_references_from_repository(repository)
 
310
        revision_info = self.info.get_revision_info(revision_id)
 
311
        inventory_revision_id = revision_id
 
312
        bundle_tree = BundleTree(repository.revision_tree(base), 
 
313
                                  inventory_revision_id)
 
314
        self._update_tree(bundle_tree, revision_id)
 
315
 
 
316
        inv = bundle_tree.inventory
 
317
        self._validate_inventory(inv, revision_id)
 
318
        self._validate_revision(inv, revision_id)
 
319
 
 
320
        return bundle_tree
 
321
 
 
322
    def _next(self):
 
323
        """yield the next line, but secretly
 
324
        keep 1 extra line for peeking.
 
325
        """
 
326
        for line in self.from_file:
 
327
            last = self._next_line
 
328
            self._next_line = line
 
329
            if last is not None:
 
330
                #mutter('yielding line: %r' % last)
 
331
                yield last
 
332
        last = self._next_line
 
333
        self._next_line = None
 
334
        #mutter('yielding line: %r' % last)
 
335
        yield last
 
336
 
 
337
    def _read_header(self):
 
338
        """Read the bzr header"""
 
339
        header = get_header()
 
340
        found = False
 
341
        for line in self._next():
 
342
            if found:
 
343
                # not all mailers will keep trailing whitespace
 
344
                if line == '#\n':
 
345
                    line = '# \n'
 
346
                if (not line.startswith('# ') or not line.endswith('\n')
 
347
                        or line[2:-1].decode('utf-8') != header[0]):
 
348
                    raise MalformedHeader('Found a header, but it'
 
349
                        ' was improperly formatted')
 
350
                header.pop(0) # We read this line.
 
351
                if not header:
 
352
                    break # We found everything.
 
353
            elif (line.startswith('#') and line.endswith('\n')):
 
354
                line = line[1:-1].strip().decode('utf-8')
 
355
                if line[:len(header_str)] == header_str:
 
356
                    if line == header[0]:
 
357
                        found = True
 
358
                    else:
 
359
                        raise MalformedHeader('Found what looks like'
 
360
                                ' a header, but did not match')
 
361
                    header.pop(0)
 
362
        else:
 
363
            raise MalformedHeader('Did not find an opening header')
 
364
 
 
365
    def _read_revision_header(self):
 
366
        self.info.revisions.append(RevisionInfo(None))
 
367
        for line in self._next():
 
368
            # The bzr header is terminated with a blank line
 
369
            # which does not start with '#'
 
370
            if line is None or line == '\n':
 
371
                break
 
372
            self._handle_next(line)
 
373
 
 
374
    def _read_next_entry(self, line, indent=1):
 
375
        """Read in a key-value pair
 
376
        """
 
377
        if not line.startswith('#'):
 
378
            raise MalformedHeader('Bzr header did not start with #')
 
379
        line = line[1:-1].decode('utf-8') # Remove the '#' and '\n'
 
380
        if line[:indent] == ' '*indent:
 
381
            line = line[indent:]
 
382
        if not line:
 
383
            return None, None# Ignore blank lines
 
384
 
 
385
        loc = line.find(': ')
 
386
        if loc != -1:
 
387
            key = line[:loc]
 
388
            value = line[loc+2:]
 
389
            if not value:
 
390
                value = self._read_many(indent=indent+2)
 
391
        elif line[-1:] == ':':
 
392
            key = line[:-1]
 
393
            value = self._read_many(indent=indent+2)
 
394
        else:
 
395
            raise MalformedHeader('While looking for key: value pairs,'
 
396
                    ' did not find the colon %r' % (line))
 
397
 
 
398
        key = key.replace(' ', '_')
 
399
        #mutter('found %s: %s' % (key, value))
 
400
        return key, value
 
401
 
 
402
    def _handle_next(self, line):
 
403
        if line is None:
 
404
            return
 
405
        key, value = self._read_next_entry(line, indent=1)
 
406
        mutter('_handle_next %r => %r' % (key, value))
 
407
        if key is None:
 
408
            return
 
409
 
 
410
        revision_info = self.info.revisions[-1]
 
411
        if hasattr(revision_info, key):
 
412
            if getattr(revision_info, key) is None:
 
413
                setattr(revision_info, key, value)
 
414
            else:
 
415
                raise MalformedHeader('Duplicated Key: %s' % key)
 
416
        else:
 
417
            # What do we do with a key we don't recognize
 
418
            raise MalformedHeader('Unknown Key: "%s"' % key)
 
419
    
 
420
    def _read_many(self, indent):
 
421
        """If a line ends with no entry, that means that it should be
 
422
        followed with multiple lines of values.
 
423
 
 
424
        This detects the end of the list, because it will be a line that
 
425
        does not start properly indented.
 
426
        """
 
427
        values = []
 
428
        start = '#' + (' '*indent)
 
429
 
 
430
        if self._next_line is None or self._next_line[:len(start)] != start:
 
431
            return values
 
432
 
 
433
        for line in self._next():
 
434
            values.append(line[len(start):-1].decode('utf-8'))
 
435
            if self._next_line is None or self._next_line[:len(start)] != start:
 
436
                break
 
437
        return values
 
438
 
 
439
    def _read_one_patch(self):
 
440
        """Read in one patch, return the complete patch, along with
 
441
        the next line.
 
442
 
 
443
        :return: action, lines, do_continue
 
444
        """
 
445
        #mutter('_read_one_patch: %r' % self._next_line)
 
446
        # Peek and see if there are no patches
 
447
        if self._next_line is None or self._next_line.startswith('#'):
 
448
            return None, [], False
 
449
 
 
450
        first = True
 
451
        lines = []
 
452
        for line in self._next():
 
453
            if first:
 
454
                if not line.startswith('==='):
 
455
                    raise MalformedPatches('The first line of all patches'
 
456
                        ' should be a bzr meta line "==="'
 
457
                        ': %r' % line)
 
458
                action = line[4:-1].decode('utf-8')
 
459
            elif line.startswith('... '):
 
460
                action += line[len('... '):-1].decode('utf-8')
 
461
 
 
462
            if (self._next_line is not None and 
 
463
                self._next_line.startswith('===')):
 
464
                return action, lines, True
 
465
            elif self._next_line is None or self._next_line.startswith('#'):
 
466
                return action, lines, False
 
467
 
 
468
            if first:
 
469
                first = False
 
470
            elif not line.startswith('... '):
 
471
                lines.append(line)
 
472
 
 
473
        return action, lines, False
 
474
            
 
475
    def _read_patches(self):
 
476
        do_continue = True
 
477
        revision_actions = []
 
478
        while do_continue:
 
479
            action, lines, do_continue = self._read_one_patch()
 
480
            if action is not None:
 
481
                revision_actions.append((action, lines))
 
482
        assert self.info.revisions[-1].tree_actions is None
 
483
        self.info.revisions[-1].tree_actions = revision_actions
 
484
 
 
485
    def _read_footer(self):
 
486
        """Read the rest of the meta information.
 
487
 
 
488
        :param first_line:  The previous step iterates past what it
 
489
                            can handle. That extra line is given here.
 
490
        """
 
491
        for line in self._next():
 
492
            self._handle_next(line)
 
493
            if not self._next_line.startswith('#'):
 
494
                self._next().next()
 
495
                break
 
496
            if self._next_line is None:
 
497
                break
 
498
 
 
499
    def _update_tree(self, bundle_tree, revision_id):
 
500
        """This fills out a BundleTree based on the information
 
501
        that was read in.
 
502
 
 
503
        :param bundle_tree: A BundleTree to update with the new information.
 
504
        """
 
505
 
 
506
        def get_rev_id(last_changed, path, kind):
 
507
            if last_changed is not None:
 
508
                changed_revision_id = last_changed.decode('utf-8')
 
509
            else:
 
510
                changed_revision_id = revision_id
 
511
            bundle_tree.note_last_changed(path, changed_revision_id)
 
512
            return changed_revision_id
 
513
 
 
514
        def extra_info(info, new_path):
 
515
            last_changed = None
 
516
            encoding = None
 
517
            for info_item in info:
 
518
                try:
 
519
                    name, value = info_item.split(':', 1)
 
520
                except ValueError:
 
521
                    raise 'Value %r has no colon' % info_item
 
522
                if name == 'last-changed':
 
523
                    last_changed = value
 
524
                elif name == 'executable':
 
525
                    assert value in ('yes', 'no'), value
 
526
                    val = (value == 'yes')
 
527
                    bundle_tree.note_executable(new_path, val)
 
528
                elif name == 'target':
 
529
                    bundle_tree.note_target(new_path, value)
 
530
                elif name == 'encoding':
 
531
                    encoding = value
 
532
            return last_changed, encoding
 
533
 
 
534
        def do_patch(path, lines, encoding):
 
535
            if encoding is not None:
 
536
                assert encoding == 'base64'
 
537
                patch = base64.decodestring(''.join(lines))
 
538
            else:
 
539
                patch =  ''.join(lines)
 
540
            bundle_tree.note_patch(path, patch)
 
541
 
 
542
        def renamed(kind, extra, lines):
 
543
            info = extra.split(' // ')
 
544
            if len(info) < 2:
 
545
                raise BzrError('renamed action lines need both a from and to'
 
546
                        ': %r' % extra)
 
547
            old_path = info[0]
 
548
            if info[1].startswith('=> '):
 
549
                new_path = info[1][3:]
 
550
            else:
 
551
                new_path = info[1]
 
552
 
 
553
            bundle_tree.note_rename(old_path, new_path)
 
554
            last_modified, encoding = extra_info(info[2:], new_path)
 
555
            revision = get_rev_id(last_modified, new_path, kind)
 
556
            if lines:
 
557
                do_patch(new_path, lines, encoding)
 
558
 
 
559
        def removed(kind, extra, lines):
 
560
            info = extra.split(' // ')
 
561
            if len(info) > 1:
 
562
                # TODO: in the future we might allow file ids to be
 
563
                # given for removed entries
 
564
                raise BzrError('removed action lines should only have the path'
 
565
                        ': %r' % extra)
 
566
            path = info[0]
 
567
            bundle_tree.note_deletion(path)
 
568
 
 
569
        def added(kind, extra, lines):
 
570
            info = extra.split(' // ')
 
571
            if len(info) <= 1:
 
572
                raise BzrError('add action lines require the path and file id'
 
573
                        ': %r' % extra)
 
574
            elif len(info) > 5:
 
575
                raise BzrError('add action lines have fewer than 5 entries.'
 
576
                        ': %r' % extra)
 
577
            path = info[0]
 
578
            if not info[1].startswith('file-id:'):
 
579
                raise BzrError('The file-id should follow the path for an add'
 
580
                        ': %r' % extra)
 
581
            file_id = info[1][8:]
 
582
 
 
583
            bundle_tree.note_id(file_id, path, kind)
 
584
            # this will be overridden in extra_info if executable is specified.
 
585
            bundle_tree.note_executable(path, False)
 
586
            last_changed, encoding = extra_info(info[2:], path)
 
587
            revision = get_rev_id(last_changed, path, kind)
 
588
            if kind == 'directory':
 
589
                return
 
590
            do_patch(path, lines, encoding)
 
591
 
 
592
        def modified(kind, extra, lines):
 
593
            info = extra.split(' // ')
 
594
            if len(info) < 1:
 
595
                raise BzrError('modified action lines have at least'
 
596
                        'the path in them: %r' % extra)
 
597
            path = info[0]
 
598
 
 
599
            last_modified, encoding = extra_info(info[1:], path)
 
600
            revision = get_rev_id(last_modified, path, kind)
 
601
            if lines:
 
602
                do_patch(path, lines, encoding)
 
603
            
 
604
        valid_actions = {
 
605
            'renamed':renamed,
 
606
            'removed':removed,
 
607
            'added':added,
 
608
            'modified':modified
 
609
        }
 
610
        for action_line, lines in \
 
611
            self.info.get_revision_info(revision_id).tree_actions:
 
612
            first = action_line.find(' ')
 
613
            if first == -1:
 
614
                raise BzrError('Bogus action line'
 
615
                        ' (no opening space): %r' % action_line)
 
616
            second = action_line.find(' ', first+1)
 
617
            if second == -1:
 
618
                raise BzrError('Bogus action line'
 
619
                        ' (missing second space): %r' % action_line)
 
620
            action = action_line[:first]
 
621
            kind = action_line[first+1:second]
 
622
            if kind not in ('file', 'directory', 'symlink'):
 
623
                raise BzrError('Bogus action line'
 
624
                        ' (invalid object kind %r): %r' % (kind, action_line))
 
625
            extra = action_line[second+1:]
 
626
 
 
627
            if action not in valid_actions:
 
628
                raise BzrError('Bogus action line'
 
629
                        ' (unrecognized action): %r' % action_line)
 
630
            valid_actions[action](kind, extra, lines)
 
631
 
 
632
 
 
633
class BundleTree(Tree):
 
634
    def __init__(self, base_tree, revision_id):
 
635
        self.base_tree = base_tree
 
636
        self._renamed = {} # Mapping from old_path => new_path
 
637
        self._renamed_r = {} # new_path => old_path
 
638
        self._new_id = {} # new_path => new_id
 
639
        self._new_id_r = {} # new_id => new_path
 
640
        self._kinds = {} # new_id => kind
 
641
        self._last_changed = {} # new_id => revision_id
 
642
        self._executable = {} # new_id => executable value
 
643
        self.patches = {}
 
644
        self._targets = {} # new path => new symlink target
 
645
        self.deleted = []
 
646
        self.contents_by_id = True
 
647
        self.revision_id = revision_id
 
648
        self._inventory = None
 
649
 
 
650
    def __str__(self):
 
651
        return pprint.pformat(self.__dict__)
 
652
 
 
653
    def note_rename(self, old_path, new_path):
 
654
        """A file/directory has been renamed from old_path => new_path"""
 
655
        assert not self._renamed.has_key(new_path)
 
656
        assert not self._renamed_r.has_key(old_path)
 
657
        self._renamed[new_path] = old_path
 
658
        self._renamed_r[old_path] = new_path
 
659
 
 
660
    def note_id(self, new_id, new_path, kind='file'):
 
661
        """Files that don't exist in base need a new id."""
 
662
        self._new_id[new_path] = new_id
 
663
        self._new_id_r[new_id] = new_path
 
664
        self._kinds[new_id] = kind
 
665
 
 
666
    def note_last_changed(self, file_id, revision_id):
 
667
        if (self._last_changed.has_key(file_id)
 
668
                and self._last_changed[file_id] != revision_id):
 
669
            raise BzrError('Mismatched last-changed revision for file_id {%s}'
 
670
                    ': %s != %s' % (file_id,
 
671
                                    self._last_changed[file_id],
 
672
                                    revision_id))
 
673
        self._last_changed[file_id] = revision_id
 
674
 
 
675
    def note_patch(self, new_path, patch):
 
676
        """There is a patch for a given filename."""
 
677
        self.patches[new_path] = patch
 
678
 
 
679
    def note_target(self, new_path, target):
 
680
        """The symlink at the new path has the given target"""
 
681
        self._targets[new_path] = target
 
682
 
 
683
    def note_deletion(self, old_path):
 
684
        """The file at old_path has been deleted."""
 
685
        self.deleted.append(old_path)
 
686
 
 
687
    def note_executable(self, new_path, executable):
 
688
        self._executable[new_path] = executable
 
689
 
 
690
    def old_path(self, new_path):
 
691
        """Get the old_path (path in the base_tree) for the file at new_path"""
 
692
        assert new_path[:1] not in ('\\', '/')
 
693
        old_path = self._renamed.get(new_path)
 
694
        if old_path is not None:
 
695
            return old_path
 
696
        dirname,basename = os.path.split(new_path)
 
697
        # dirname is not '' doesn't work, because
 
698
        # dirname may be a unicode entry, and is
 
699
        # requires the objects to be identical
 
700
        if dirname != '':
 
701
            old_dir = self.old_path(dirname)
 
702
            if old_dir is None:
 
703
                old_path = None
 
704
            else:
 
705
                old_path = os.path.join(old_dir, basename)
 
706
        else:
 
707
            old_path = new_path
 
708
        #If the new path wasn't in renamed, the old one shouldn't be in
 
709
        #renamed_r
 
710
        if self._renamed_r.has_key(old_path):
 
711
            return None
 
712
        return old_path 
 
713
 
 
714
    def new_path(self, old_path):
 
715
        """Get the new_path (path in the target_tree) for the file at old_path
 
716
        in the base tree.
 
717
        """
 
718
        assert old_path[:1] not in ('\\', '/')
 
719
        new_path = self._renamed_r.get(old_path)
 
720
        if new_path is not None:
 
721
            return new_path
 
722
        if self._renamed.has_key(new_path):
 
723
            return None
 
724
        dirname,basename = os.path.split(old_path)
 
725
        if dirname != '':
 
726
            new_dir = self.new_path(dirname)
 
727
            if new_dir is None:
 
728
                new_path = None
 
729
            else:
 
730
                new_path = os.path.join(new_dir, basename)
 
731
        else:
 
732
            new_path = old_path
 
733
        #If the old path wasn't in renamed, the new one shouldn't be in
 
734
        #renamed_r
 
735
        if self._renamed.has_key(new_path):
 
736
            return None
 
737
        return new_path 
 
738
 
 
739
    def path2id(self, path):
 
740
        """Return the id of the file present at path in the target tree."""
 
741
        file_id = self._new_id.get(path)
 
742
        if file_id is not None:
 
743
            return file_id
 
744
        old_path = self.old_path(path)
 
745
        if old_path is None:
 
746
            return None
 
747
        if old_path in self.deleted:
 
748
            return None
 
749
        if hasattr(self.base_tree, 'path2id'):
 
750
            return self.base_tree.path2id(old_path)
 
751
        else:
 
752
            return self.base_tree.inventory.path2id(old_path)
 
753
 
 
754
    def id2path(self, file_id):
 
755
        """Return the new path in the target tree of the file with id file_id"""
 
756
        path = self._new_id_r.get(file_id)
 
757
        if path is not None:
 
758
            return path
 
759
        old_path = self.base_tree.id2path(file_id)
 
760
        if old_path is None:
 
761
            return None
 
762
        if old_path in self.deleted:
 
763
            return None
 
764
        return self.new_path(old_path)
 
765
 
 
766
    def old_contents_id(self, file_id):
 
767
        """Return the id in the base_tree for the given file_id.
 
768
        Return None if the file did not exist in base.
 
769
        """
 
770
        if self.contents_by_id:
 
771
            if self.base_tree.has_id(file_id):
 
772
                return file_id
 
773
            else:
 
774
                return None
 
775
        new_path = self.id2path(file_id)
 
776
        return self.base_tree.path2id(new_path)
 
777
        
 
778
    def get_file(self, file_id):
 
779
        """Return a file-like object containing the new contents of the
 
780
        file given by file_id.
 
781
 
 
782
        TODO:   It might be nice if this actually generated an entry
 
783
                in the text-store, so that the file contents would
 
784
                then be cached.
 
785
        """
 
786
        base_id = self.old_contents_id(file_id)
 
787
        if base_id is not None:
 
788
            patch_original = self.base_tree.get_file(base_id)
 
789
        else:
 
790
            patch_original = None
 
791
        file_patch = self.patches.get(self.id2path(file_id))
 
792
        if file_patch is None:
 
793
            if (patch_original is None and 
 
794
                self.get_kind(file_id) == 'directory'):
 
795
                return StringIO()
 
796
            assert patch_original is not None, "None: %s" % file_id
 
797
            return patch_original
 
798
 
 
799
        assert not file_patch.startswith('\\'), \
 
800
            'Malformed patch for %s, %r' % (file_id, file_patch)
 
801
        return patched_file(file_patch, patch_original)
 
802
 
 
803
    def get_symlink_target(self, file_id):
 
804
        new_path = self.id2path(file_id)
 
805
        try:
 
806
            return self._targets[new_path]
 
807
        except KeyError:
 
808
            return self.base_tree.get_symlink_target(file_id)
 
809
 
 
810
    def get_kind(self, file_id):
 
811
        if file_id in self._kinds:
 
812
            return self._kinds[file_id]
 
813
        return self.base_tree.inventory[file_id].kind
 
814
 
 
815
    def is_executable(self, file_id):
 
816
        path = self.id2path(file_id)
 
817
        if path in self._executable:
 
818
            return self._executable[path]
 
819
        else:
 
820
            return self.base_tree.inventory[file_id].executable
 
821
 
 
822
    def get_last_changed(self, file_id):
 
823
        path = self.id2path(file_id)
 
824
        if path in self._last_changed:
 
825
            return self._last_changed[path]
 
826
        return self.base_tree.inventory[file_id].revision
 
827
 
 
828
    def get_size_and_sha1(self, file_id):
 
829
        """Return the size and sha1 hash of the given file id.
 
830
        If the file was not locally modified, this is extracted
 
831
        from the base_tree. Rather than re-reading the file.
 
832
        """
 
833
        new_path = self.id2path(file_id)
 
834
        if new_path is None:
 
835
            return None, None
 
836
        if new_path not in self.patches:
 
837
            # If the entry does not have a patch, then the
 
838
            # contents must be the same as in the base_tree
 
839
            ie = self.base_tree.inventory[file_id]
 
840
            if ie.text_size is None:
 
841
                return ie.text_size, ie.text_sha1
 
842
            return int(ie.text_size), ie.text_sha1
 
843
        fileobj = self.get_file(file_id)
 
844
        content = fileobj.read()
 
845
        return len(content), sha_string(content)
 
846
 
 
847
    def _get_inventory(self):
 
848
        """Build up the inventory entry for the BundleTree.
 
849
 
 
850
        This need to be called before ever accessing self.inventory
 
851
        """
 
852
        from os.path import dirname, basename
 
853
 
 
854
        assert self.base_tree is not None
 
855
        base_inv = self.base_tree.inventory
 
856
        root_id = base_inv.root.file_id
 
857
        try:
 
858
            # New inventories have a unique root_id
 
859
            inv = Inventory(root_id, self.revision_id)
 
860
        except TypeError:
 
861
            inv = Inventory(revision_id=self.revision_id)
 
862
 
 
863
        def add_entry(file_id):
 
864
            path = self.id2path(file_id)
 
865
            if path is None:
 
866
                return
 
867
            parent_path = dirname(path)
 
868
            if parent_path == u'':
 
869
                parent_id = root_id
 
870
            else:
 
871
                parent_id = self.path2id(parent_path)
 
872
 
 
873
            kind = self.get_kind(file_id)
 
874
            revision_id = self.get_last_changed(file_id)
 
875
 
 
876
            name = basename(path)
 
877
            if kind == 'directory':
 
878
                ie = InventoryDirectory(file_id, name, parent_id)
 
879
            elif kind == 'file':
 
880
                ie = InventoryFile(file_id, name, parent_id)
 
881
                ie.executable = self.is_executable(file_id)
 
882
            elif kind == 'symlink':
 
883
                ie = InventoryLink(file_id, name, parent_id)
 
884
                ie.symlink_target = self.get_symlink_target(file_id)
 
885
            ie.revision = revision_id
 
886
 
 
887
            if kind in ('directory', 'symlink'):
 
888
                ie.text_size, ie.text_sha1 = None, None
 
889
            else:
 
890
                ie.text_size, ie.text_sha1 = self.get_size_and_sha1(file_id)
 
891
            if (ie.text_size is None) and (kind == 'file'):
 
892
                raise BzrError('Got a text_size of None for file_id %r' % file_id)
 
893
            inv.add(ie)
 
894
 
 
895
        sorted_entries = self.sorted_path_id()
 
896
        for path, file_id in sorted_entries:
 
897
            if file_id == inv.root.file_id:
 
898
                continue
 
899
            add_entry(file_id)
 
900
 
 
901
        return inv
 
902
 
 
903
    # Have to overload the inherited inventory property
 
904
    # because _get_inventory is only called in the parent.
 
905
    # Reading the docs, property() objects do not use
 
906
    # overloading, they use the function as it was defined
 
907
    # at that instant
 
908
    inventory = property(_get_inventory)
 
909
 
 
910
    def __iter__(self):
 
911
        for path, entry in self.inventory.iter_entries():
 
912
            yield entry.file_id
 
913
 
 
914
    def sorted_path_id(self):
 
915
        paths = []
 
916
        for result in self._new_id.iteritems():
 
917
            paths.append(result)
 
918
        for id in self.base_tree:
 
919
            path = self.id2path(id)
 
920
            if path is None:
 
921
                continue
 
922
            paths.append((path, id))
 
923
        paths.sort()
 
924
        return paths
 
925
 
 
926
 
 
927
def patched_file(file_patch, original):
 
928
    """Produce a file-like object with the patched version of a text"""
 
929
    from bzrlib.patches import iter_patched
 
930
    from bzrlib.iterablefile import IterableFile
 
931
    if file_patch == "":
 
932
        return IterableFile(())
 
933
    return IterableFile(iter_patched(original, file_patch.splitlines(True)))