/brz/remove-bazaar

To get this branch, use:
bzr branch http://gegoxaren.bato24.eu/bzr/brz/remove-bazaar

« back to all changes in this revision

Viewing changes to bzrlib/bundle/read_bundle.py

  • Committer: Martin Pool
  • Date: 2006-06-20 03:30:14 UTC
  • mfrom: (1793 +trunk)
  • mto: This revision was merged to the branch mainline in revision 1797.
  • Revision ID: mbp@sourcefrog.net-20060620033014-e19ce470e2ce6561
[merge] bzr.dev

Show diffs side-by-side

added added

removed removed

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