/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 bzr_commit_handler.py

fix date parsing bug found while importing samba

Show diffs side-by-side

added added

removed removed

Lines of Context:
 
1
# Copyright (C) 2008 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
"""CommitHandlers that build and save revisions & their inventories."""
 
18
 
 
19
 
 
20
from bzrlib import (
 
21
    errors,
 
22
    generate_ids,
 
23
    inventory,
 
24
    osutils,
 
25
    revision,
 
26
    serializer,
 
27
    )
 
28
from bzrlib.plugins.fastimport import commands, helpers, processor
 
29
 
 
30
 
 
31
_serializer_handles_escaping = hasattr(serializer.Serializer,
 
32
    'squashes_xml_invalid_characters')
 
33
 
 
34
 
 
35
def copy_inventory(inv):
 
36
    # This currently breaks revision-id matching
 
37
    #if hasattr(inv, "_get_mutable_inventory"):
 
38
    #    # TODO: Make this a public API on inventory
 
39
    #    return inv._get_mutable_inventory()
 
40
 
 
41
    # TODO: Shallow copy - deep inventory copying is expensive
 
42
    return inv.copy()
 
43
 
 
44
 
 
45
class GenericCommitHandler(processor.CommitHandler):
 
46
    """Base class for Bazaar CommitHandlers."""
 
47
 
 
48
    def __init__(self, command, cache_mgr, rev_store, verbose=False,
 
49
        prune_empty_dirs=True):
 
50
        super(GenericCommitHandler, self).__init__(command)
 
51
        self.cache_mgr = cache_mgr
 
52
        self.rev_store = rev_store
 
53
        self.verbose = verbose
 
54
        self.branch_ref = command.ref
 
55
        self.prune_empty_dirs = prune_empty_dirs
 
56
        # This tracks path->file-id for things we're creating this commit.
 
57
        # If the same path is created multiple times, we need to warn the
 
58
        # user and add it just once.
 
59
        # If a path is added then renamed or copied, we need to handle that.
 
60
        self._new_file_ids = {}
 
61
        # This tracks path->file-id for things we're modifying this commit.
 
62
        # If a path is modified then renamed or copied, we need the make
 
63
        # sure we grab the new content.
 
64
        self._modified_file_ids = {}
 
65
        # This tracks the paths for things we're deleting this commit.
 
66
        # If the same path is added or the destination of a rename say,
 
67
        # then a fresh file-id is required.
 
68
        self._paths_deleted_this_commit = set()
 
69
 
 
70
    def pre_process_files(self):
 
71
        """Prepare for committing."""
 
72
        self.revision_id = self.gen_revision_id()
 
73
        # cache of texts for this commit, indexed by file-id
 
74
        self.lines_for_commit = {}
 
75
        #if self.rev_store.expects_rich_root():
 
76
        self.lines_for_commit[inventory.ROOT_ID] = []
 
77
 
 
78
        # Track the heads and get the real parent list
 
79
        parents = self.cache_mgr.track_heads(self.command)
 
80
 
 
81
        # Convert the parent commit-ids to bzr revision-ids
 
82
        if parents:
 
83
            self.parents = [self.cache_mgr.revision_ids[p]
 
84
                for p in parents]
 
85
        else:
 
86
            self.parents = []
 
87
        self.debug("%s id: %s, parents: %s", self.command.id,
 
88
            self.revision_id, str(self.parents))
 
89
 
 
90
        # Tell the RevisionStore we're starting a new commit
 
91
        self.revision = self.build_revision()
 
92
        self.parent_invs = [self.get_inventory(p) for p in self.parents]
 
93
        self.rev_store.start_new_revision(self.revision, self.parents,
 
94
            self.parent_invs)
 
95
 
 
96
        # cache of per-file parents for this commit, indexed by file-id
 
97
        self.per_file_parents_for_commit = {}
 
98
        if self.rev_store.expects_rich_root():
 
99
            self.per_file_parents_for_commit[inventory.ROOT_ID] = ()
 
100
 
 
101
        # Keep the basis inventory. This needs to be treated as read-only.
 
102
        if len(self.parents) == 0:
 
103
            self.basis_inventory = self._init_inventory()
 
104
        else:
 
105
            self.basis_inventory = self.get_inventory(self.parents[0])
 
106
        if hasattr(self.basis_inventory, "root_id"):
 
107
            self.inventory_root_id = self.basis_inventory.root_id
 
108
        else:
 
109
            self.inventory_root_id = self.basis_inventory.root.file_id
 
110
 
 
111
        # directory-path -> inventory-entry for current inventory
 
112
        self.directory_entries = {}
 
113
 
 
114
    def _init_inventory(self):
 
115
        return self.rev_store.init_inventory(self.revision_id)
 
116
 
 
117
    def get_inventory(self, revision_id):
 
118
        """Get the inventory for a revision id."""
 
119
        try:
 
120
            inv = self.cache_mgr.inventories[revision_id]
 
121
        except KeyError:
 
122
            if self.verbose:
 
123
                self.mutter("get_inventory cache miss for %s", revision_id)
 
124
            # Not cached so reconstruct from the RevisionStore
 
125
            inv = self.rev_store.get_inventory(revision_id)
 
126
            self.cache_mgr.inventories[revision_id] = inv
 
127
        return inv
 
128
 
 
129
    def _get_lines(self, file_id):
 
130
        """Get the lines for a file-id."""
 
131
        return self.lines_for_commit[file_id]
 
132
 
 
133
    def _get_per_file_parents(self, file_id):
 
134
        """Get the lines for a file-id."""
 
135
        return self.per_file_parents_for_commit[file_id]
 
136
 
 
137
    def _get_inventories(self, revision_ids):
 
138
        """Get the inventories for revision-ids.
 
139
        
 
140
        This is a callback used by the RepositoryStore to
 
141
        speed up inventory reconstruction.
 
142
        """
 
143
        present = []
 
144
        inventories = []
 
145
        # If an inventory is in the cache, we assume it was
 
146
        # successfully loaded into the revision store
 
147
        for revision_id in revision_ids:
 
148
            try:
 
149
                inv = self.cache_mgr.inventories[revision_id]
 
150
                present.append(revision_id)
 
151
            except KeyError:
 
152
                if self.verbose:
 
153
                    self.note("get_inventories cache miss for %s", revision_id)
 
154
                # Not cached so reconstruct from the revision store
 
155
                try:
 
156
                    inv = self.get_inventory(revision_id)
 
157
                    present.append(revision_id)
 
158
                except:
 
159
                    inv = self._init_inventory()
 
160
                self.cache_mgr.inventories[revision_id] = inv
 
161
            inventories.append(inv)
 
162
        return present, inventories
 
163
 
 
164
    def bzr_file_id_and_new(self, path):
 
165
        """Get a Bazaar file identifier and new flag for a path.
 
166
        
 
167
        :return: file_id, is_new where
 
168
          is_new = True if the file_id is newly created
 
169
        """
 
170
        if path not in self._paths_deleted_this_commit:
 
171
            # Try file-ids renamed in this commit
 
172
            id = self._modified_file_ids.get(path)
 
173
            if id is not None:
 
174
                return id, False
 
175
 
 
176
            # Try the basis inventory
 
177
            id = self.basis_inventory.path2id(path)
 
178
            if id is not None:
 
179
                return id, False
 
180
            
 
181
            # Try the other inventories
 
182
            if len(self.parents) > 1:
 
183
                for inv in self.parent_invs[1:]:
 
184
                    id = self.basis_inventory.path2id(path)
 
185
                    if id is not None:
 
186
                        return id, False
 
187
 
 
188
        # Doesn't exist yet so create it
 
189
        id = generate_ids.gen_file_id(path)
 
190
        self.debug("Generated new file id %s for '%s' in revision-id '%s'",
 
191
            id, path, self.revision_id)
 
192
        self._new_file_ids[path] = id
 
193
        return id, True
 
194
 
 
195
    def bzr_file_id(self, path):
 
196
        """Get a Bazaar file identifier for a path."""
 
197
        return self.bzr_file_id_and_new(path)[0]
 
198
 
 
199
    def _format_name_email(self, name, email):
 
200
        """Format name & email as a string."""
 
201
        if email:
 
202
            return "%s <%s>" % (name, email)
 
203
        else:
 
204
            return name
 
205
 
 
206
    def gen_revision_id(self):
 
207
        """Generate a revision id.
 
208
 
 
209
        Subclasses may override this to produce deterministic ids say.
 
210
        """
 
211
        committer = self.command.committer
 
212
        # Perhaps 'who' being the person running the import is ok? If so,
 
213
        # it might be a bit quicker and give slightly better compression?
 
214
        who = self._format_name_email(committer[0], committer[1])
 
215
        timestamp = committer[2]
 
216
        return generate_ids.gen_revision_id(who, timestamp)
 
217
 
 
218
    def build_revision(self):
 
219
        rev_props = self._legal_revision_properties(self.command.properties)
 
220
        self._save_author_info(rev_props)
 
221
        committer = self.command.committer
 
222
        who = self._format_name_email(committer[0], committer[1])
 
223
        message = self.command.message
 
224
        if not _serializer_handles_escaping:
 
225
            # We need to assume the bad ol' days
 
226
            message = helpers.escape_commit_message(message)
 
227
        return revision.Revision(
 
228
           timestamp=committer[2],
 
229
           timezone=committer[3],
 
230
           committer=who,
 
231
           message=message,
 
232
           revision_id=self.revision_id,
 
233
           properties=rev_props,
 
234
           parent_ids=self.parents)
 
235
 
 
236
    def _legal_revision_properties(self, props):
 
237
        """Clean-up any revision properties we can't handle."""
 
238
        # For now, we just check for None because that's not allowed in 2.0rc1
 
239
        result = {}
 
240
        if props is not None:
 
241
            for name, value in props.items():
 
242
                if value is None:
 
243
                    self.warning(
 
244
                        "converting None to empty string for property %s"
 
245
                        % (name,))
 
246
                    result[name] = ''
 
247
                else:
 
248
                    result[name] = value
 
249
        return result
 
250
 
 
251
    def _save_author_info(self, rev_props):
 
252
        author = self.command.author
 
253
        if author is None:
 
254
            return
 
255
        if self.command.more_authors:
 
256
            authors = [author] + self.command.more_authors
 
257
            author_ids = [self._format_name_email(a[0], a[1]) for a in authors]
 
258
        elif author != self.command.committer:
 
259
            author_ids = [self._format_name_email(author[0], author[1])]
 
260
        else:
 
261
            return
 
262
        # If we reach here, there are authors worth storing
 
263
        rev_props['authors'] = "\n".join(author_ids)
 
264
 
 
265
    def _modify_item(self, path, kind, is_executable, data, inv):
 
266
        """Add to or change an item in the inventory."""
 
267
        # If we've already added this, warn the user that we're ignoring it.
 
268
        # In the future, it might be nice to double check that the new data
 
269
        # is the same as the old but, frankly, exporters should be fixed
 
270
        # not to produce bad data streams in the first place ...
 
271
        existing = self._new_file_ids.get(path)
 
272
        if existing:
 
273
            # We don't warn about directories because it's fine for them
 
274
            # to be created already by a previous rename
 
275
            if kind != 'directory':
 
276
                self.warning("%s already added in this commit - ignoring" %
 
277
                    (path,))
 
278
            return
 
279
 
 
280
        # Create the new InventoryEntry
 
281
        basename, parent_id = self._ensure_directory(path, inv)
 
282
        file_id = self.bzr_file_id(path)
 
283
        ie = inventory.make_entry(kind, basename, parent_id, file_id)
 
284
        ie.revision = self.revision_id
 
285
        if kind == 'file':
 
286
            ie.executable = is_executable
 
287
            lines = osutils.split_lines(data)
 
288
            ie.text_sha1 = osutils.sha_strings(lines)
 
289
            ie.text_size = sum(map(len, lines))
 
290
            self.lines_for_commit[file_id] = lines
 
291
        elif kind == 'directory':
 
292
            self.directory_entries[path] = ie
 
293
            # There are no lines stored for a directory so
 
294
            # make sure the cache used by get_lines knows that
 
295
            self.lines_for_commit[file_id] = []
 
296
        elif kind == 'symlink':
 
297
            ie.symlink_target = data.encode('utf8')
 
298
            # There are no lines stored for a symlink so
 
299
            # make sure the cache used by get_lines knows that
 
300
            self.lines_for_commit[file_id] = []
 
301
        else:
 
302
            self.warning("Cannot import items of kind '%s' yet - ignoring '%s'"
 
303
                % (kind, path))
 
304
            return
 
305
        # Record it
 
306
        if file_id in inv:
 
307
            old_ie = inv[file_id]
 
308
            if old_ie.kind == 'directory':
 
309
                self.record_delete(path, old_ie)
 
310
            self.record_changed(path, ie, parent_id)
 
311
        else:
 
312
            try:
 
313
                self.record_new(path, ie)
 
314
            except:
 
315
                print "failed to add path '%s' with entry '%s' in command %s" \
 
316
                    % (path, ie, self.command.id)
 
317
                print "parent's children are:\n%r\n" % (ie.parent_id.children,)
 
318
                raise
 
319
 
 
320
    def _ensure_directory(self, path, inv):
 
321
        """Ensure that the containing directory exists for 'path'"""
 
322
        dirname, basename = osutils.split(path)
 
323
        if dirname == '':
 
324
            # the root node doesn't get updated
 
325
            return basename, self.inventory_root_id
 
326
        try:
 
327
            ie = self._get_directory_entry(inv, dirname)
 
328
        except KeyError:
 
329
            # We will create this entry, since it doesn't exist
 
330
            pass
 
331
        else:
 
332
            return basename, ie.file_id
 
333
 
 
334
        # No directory existed, we will just create one, first, make sure
 
335
        # the parent exists
 
336
        dir_basename, parent_id = self._ensure_directory(dirname, inv)
 
337
        dir_file_id = self.bzr_file_id(dirname)
 
338
        ie = inventory.entry_factory['directory'](dir_file_id,
 
339
            dir_basename, parent_id)
 
340
        ie.revision = self.revision_id
 
341
        self.directory_entries[dirname] = ie
 
342
        # There are no lines stored for a directory so
 
343
        # make sure the cache used by get_lines knows that
 
344
        self.lines_for_commit[dir_file_id] = []
 
345
 
 
346
        # It's possible that a file or symlink with that file-id
 
347
        # already exists. If it does, we need to delete it.
 
348
        if dir_file_id in inv:
 
349
            self.record_delete(dirname, ie)
 
350
        self.record_new(dirname, ie)
 
351
        return basename, ie.file_id
 
352
 
 
353
    def _get_directory_entry(self, inv, dirname):
 
354
        """Get the inventory entry for a directory.
 
355
        
 
356
        Raises KeyError if dirname is not a directory in inv.
 
357
        """
 
358
        result = self.directory_entries.get(dirname)
 
359
        if result is None:
 
360
            if dirname in self._paths_deleted_this_commit:
 
361
                raise KeyError
 
362
            try:
 
363
                file_id = inv.path2id(dirname)
 
364
            except errors.NoSuchId:
 
365
                # In a CHKInventory, this is raised if there's no root yet
 
366
                raise KeyError
 
367
            if file_id is None:
 
368
                raise KeyError
 
369
            result = inv[file_id]
 
370
            # dirname must be a directory for us to return it
 
371
            if result.kind == 'directory':
 
372
                self.directory_entries[dirname] = result
 
373
            else:
 
374
                raise KeyError
 
375
        return result
 
376
 
 
377
    def _delete_item(self, path, inv):
 
378
        newly_added = self._new_file_ids.get(path)
 
379
        if newly_added:
 
380
            # We've only just added this path earlier in this commit.
 
381
            file_id = newly_added
 
382
            # note: delta entries look like (old, new, file-id, ie)
 
383
            ie = self._delta_entries_by_fileid[file_id][3]
 
384
        else:
 
385
            file_id = inv.path2id(path)
 
386
            if file_id is None:
 
387
                self.mutter("ignoring delete of %s as not in inventory", path)
 
388
                return
 
389
            try:
 
390
                ie = inv[file_id]
 
391
            except errors.NoSuchId:
 
392
                self.mutter("ignoring delete of %s as not in inventory", path)
 
393
                return
 
394
        self.record_delete(path, ie)
 
395
 
 
396
    def _copy_item(self, src_path, dest_path, inv):
 
397
        newly_changed = self._new_file_ids.get(src_path) or \
 
398
            self._modified_file_ids.get(src_path)
 
399
        if newly_changed:
 
400
            # We've only just added/changed this path earlier in this commit.
 
401
            file_id = newly_changed
 
402
            # note: delta entries look like (old, new, file-id, ie)
 
403
            ie = self._delta_entries_by_fileid[file_id][3]
 
404
        else:
 
405
            file_id = inv.path2id(src_path)
 
406
            if file_id is None:
 
407
                self.warning("ignoring copy of %s to %s - source does not exist",
 
408
                    src_path, dest_path)
 
409
                return
 
410
            ie = inv[file_id]
 
411
        kind = ie.kind
 
412
        if kind == 'file':
 
413
            if newly_changed:
 
414
                content = ''.join(self.lines_for_commit[file_id])
 
415
            else:
 
416
                content = self.rev_store.get_file_text(self.parents[0], file_id)
 
417
            self._modify_item(dest_path, kind, ie.executable, content, inv)
 
418
        elif kind == 'symlink':
 
419
            self._modify_item(dest_path, kind, False, ie.symlink_target, inv)
 
420
        else:
 
421
            self.warning("ignoring copy of %s %s - feature not yet supported",
 
422
                kind, path)
 
423
 
 
424
    def _rename_item(self, old_path, new_path, inv):
 
425
        existing = self._new_file_ids.get(old_path) or \
 
426
            self._modified_file_ids.get(old_path)
 
427
        if existing:
 
428
            # We've only just added/modified this path earlier in this commit.
 
429
            # Change the add/modify of old_path to an add of new_path
 
430
            self._rename_pending_change(old_path, new_path, existing)
 
431
            return
 
432
 
 
433
        file_id = inv.path2id(old_path)
 
434
        if file_id is None:
 
435
            self.warning(
 
436
                "ignoring rename of %s to %s - old path does not exist" %
 
437
                (old_path, new_path))
 
438
            return
 
439
        ie = inv[file_id]
 
440
        rev_id = ie.revision
 
441
        new_file_id = inv.path2id(new_path)
 
442
        if new_file_id is not None:
 
443
            self.record_delete(new_path, inv[new_file_id])
 
444
        self.record_rename(old_path, new_path, file_id, ie)
 
445
 
 
446
        # The revision-id for this entry will be/has been updated and
 
447
        # that means the loader then needs to know what the "new" text is.
 
448
        # We therefore must go back to the revision store to get it.
 
449
        lines = self.rev_store.get_file_lines(rev_id, file_id)
 
450
        self.lines_for_commit[file_id] = lines
 
451
 
 
452
    def _delete_all_items(self, inv):
 
453
        for name, root_item in inv.root.children.iteritems():
 
454
            inv.remove_recursive_id(root_item.file_id)
 
455
 
 
456
    def _warn_unless_in_merges(self, fileid, path):
 
457
        if len(self.parents) <= 1:
 
458
            return
 
459
        for parent in self.parents[1:]:
 
460
            if fileid in self.get_inventory(parent):
 
461
                return
 
462
        self.warning("ignoring delete of %s as not in parent inventories", path)
 
463
 
 
464
 
 
465
class InventoryCommitHandler(GenericCommitHandler):
 
466
    """A CommitHandler that builds and saves Inventory objects."""
 
467
 
 
468
    def pre_process_files(self):
 
469
        super(InventoryCommitHandler, self).pre_process_files()
 
470
 
 
471
        # Seed the inventory from the previous one. Note that
 
472
        # the parent class version of pre_process_files() has
 
473
        # already set the right basis_inventory for this branch
 
474
        # but we need to copy it in order to mutate it safely
 
475
        # without corrupting the cached inventory value.
 
476
        if len(self.parents) == 0:
 
477
            self.inventory = self.basis_inventory
 
478
        else:
 
479
            self.inventory = copy_inventory(self.basis_inventory)
 
480
        self.inventory_root = self.inventory.root
 
481
 
 
482
        # directory-path -> inventory-entry for current inventory
 
483
        self.directory_entries = dict(self.inventory.directories())
 
484
 
 
485
        # Initialise the inventory revision info as required
 
486
        if self.rev_store.expects_rich_root():
 
487
            self.inventory.revision_id = self.revision_id
 
488
        else:
 
489
            # In this revision store, root entries have no knit or weave.
 
490
            # When serializing out to disk and back in, root.revision is
 
491
            # always the new revision_id.
 
492
            self.inventory.root.revision = self.revision_id
 
493
 
 
494
    def post_process_files(self):
 
495
        """Save the revision."""
 
496
        self.cache_mgr.inventories[self.revision_id] = self.inventory
 
497
        self.rev_store.load(self.revision, self.inventory, None,
 
498
            lambda file_id: self._get_lines(file_id),
 
499
            lambda file_id: self._get_per_file_parents(file_id),
 
500
            lambda revision_ids: self._get_inventories(revision_ids))
 
501
 
 
502
    def record_new(self, path, ie):
 
503
        try:
 
504
            # If this is a merge, the file was most likely added already.
 
505
            # The per-file parent(s) must therefore be calculated and
 
506
            # we can't assume there are none.
 
507
            per_file_parents, ie.revision = \
 
508
                self.rev_store.get_parents_and_revision_for_entry(ie)
 
509
            self.per_file_parents_for_commit[ie.file_id] = per_file_parents
 
510
            self.inventory.add(ie)
 
511
        except errors.DuplicateFileId:
 
512
            # Directory already exists as a file or symlink
 
513
            del self.inventory[ie.file_id]
 
514
            # Try again
 
515
            self.inventory.add(ie)
 
516
 
 
517
    def record_changed(self, path, ie, parent_id):
 
518
        # HACK: no API for this (del+add does more than it needs to)
 
519
        per_file_parents, ie.revision = \
 
520
            self.rev_store.get_parents_and_revision_for_entry(ie)
 
521
        self.per_file_parents_for_commit[ie.file_id] = per_file_parents
 
522
        self.inventory._byid[ie.file_id] = ie
 
523
        parent_ie = self.inventory._byid[parent_id]
 
524
        parent_ie.children[ie.name] = ie
 
525
 
 
526
    def record_delete(self, path, ie):
 
527
        self.inventory.remove_recursive_id(ie.file_id)
 
528
 
 
529
    def record_rename(self, old_path, new_path, file_id, ie):
 
530
        # For a rename, the revision-id is always the new one so
 
531
        # no need to change/set it here
 
532
        ie.revision = self.revision_id
 
533
        per_file_parents, _ = \
 
534
            self.rev_store.get_parents_and_revision_for_entry(ie)
 
535
        self.per_file_parents_for_commit[file_id] = per_file_parents
 
536
        new_basename, new_parent_id = self._ensure_directory(new_path,
 
537
            self.inventory)
 
538
        self.inventory.rename(file_id, new_parent_id, new_basename)
 
539
 
 
540
    def modify_handler(self, filecmd):
 
541
        if filecmd.dataref is not None:
 
542
            data = self.cache_mgr.fetch_blob(filecmd.dataref)
 
543
        else:
 
544
            data = filecmd.data
 
545
        self.debug("modifying %s", filecmd.path)
 
546
        self._modify_item(filecmd.path, filecmd.kind,
 
547
            filecmd.is_executable, data, self.inventory)
 
548
 
 
549
    def delete_handler(self, filecmd):
 
550
        self.debug("deleting %s", filecmd.path)
 
551
        self._delete_item(filecmd.path, self.inventory)
 
552
 
 
553
    def copy_handler(self, filecmd):
 
554
        src_path = filecmd.src_path
 
555
        dest_path = filecmd.dest_path
 
556
        self.debug("copying %s to %s", src_path, dest_path)
 
557
        self._copy_item(src_path, dest_path, self.inventory)
 
558
 
 
559
    def rename_handler(self, filecmd):
 
560
        old_path = filecmd.old_path
 
561
        new_path = filecmd.new_path
 
562
        self.debug("renaming %s to %s", old_path, new_path)
 
563
        self._rename_item(old_path, new_path, self.inventory)
 
564
 
 
565
    def deleteall_handler(self, filecmd):
 
566
        self.debug("deleting all files (and also all directories)")
 
567
        self._delete_all_items(self.inventory)
 
568
 
 
569
 
 
570
class InventoryDeltaCommitHandler(GenericCommitHandler):
 
571
    """A CommitHandler that builds Inventories by applying a delta."""
 
572
 
 
573
    def pre_process_files(self):
 
574
        super(InventoryDeltaCommitHandler, self).pre_process_files()
 
575
        self._dirs_that_might_become_empty = set()
 
576
 
 
577
        # A given file-id can only appear once so we accumulate
 
578
        # the entries in a dict then build the actual delta at the end
 
579
        self._delta_entries_by_fileid = {}
 
580
        if len(self.parents) == 0 or not self.rev_store.expects_rich_root():
 
581
            if self.parents:
 
582
                old_path = ''
 
583
            else:
 
584
                old_path = None
 
585
            # Need to explicitly add the root entry for the first revision
 
586
            # and for non rich-root inventories
 
587
            root_id = inventory.ROOT_ID
 
588
            root_ie = inventory.InventoryDirectory(root_id, u'', None)
 
589
            root_ie.revision = self.revision_id
 
590
            self._add_entry((old_path, '', root_id, root_ie))
 
591
 
 
592
    def post_process_files(self):
 
593
        """Save the revision."""
 
594
        delta = self._get_final_delta()
 
595
        inv = self.rev_store.load_using_delta(self.revision,
 
596
            self.basis_inventory, delta, None,
 
597
            lambda file_id: self._get_lines(file_id),
 
598
            lambda file_id: self._get_per_file_parents(file_id),
 
599
            lambda revision_ids: self._get_inventories(revision_ids))
 
600
        self.cache_mgr.inventories[self.revision_id] = inv
 
601
        #print "committed %s" % self.revision_id
 
602
 
 
603
    def _get_final_delta(self):
 
604
        """Generate the final delta.
 
605
 
 
606
        Smart post-processing of changes, e.g. pruning of directories
 
607
        that would become empty, goes here.
 
608
        """
 
609
        delta = list(self._delta_entries_by_fileid.values())
 
610
        if self.prune_empty_dirs and self._dirs_that_might_become_empty:
 
611
            candidates = self._dirs_that_might_become_empty
 
612
            while candidates:
 
613
                never_born = set()
 
614
                parent_dirs_that_might_become_empty = set()
 
615
                for path, file_id in self._empty_after_delta(delta, candidates):
 
616
                    newly_added = self._new_file_ids.get(path)
 
617
                    if newly_added:
 
618
                        never_born.add(newly_added)
 
619
                    else:
 
620
                        delta.append((path, None, file_id, None))
 
621
                    parent_dir = osutils.dirname(path)
 
622
                    if parent_dir:
 
623
                        parent_dirs_that_might_become_empty.add(parent_dir)
 
624
                candidates = parent_dirs_that_might_become_empty
 
625
                # Clean up entries that got deleted before they were ever added
 
626
                if never_born:
 
627
                    delta = [de for de in delta if de[2] not in never_born]
 
628
        return delta
 
629
 
 
630
    def _empty_after_delta(self, delta, candidates):
 
631
        #self.mutter("delta so far is:\n%s" % "\n".join([str(de) for de in delta]))
 
632
        #self.mutter("candidates for deletion are:\n%s" % "\n".join([c for c in candidates]))
 
633
        new_inv = self._get_proposed_inventory(delta)
 
634
        result = []
 
635
        for dir in candidates:
 
636
            file_id = new_inv.path2id(dir)
 
637
            if file_id is None:
 
638
                continue
 
639
            ie = new_inv[file_id]
 
640
            if ie.kind != 'directory':
 
641
                continue
 
642
            if len(ie.children) == 0:
 
643
                result.append((dir, file_id))
 
644
                if self.verbose:
 
645
                    self.note("pruning empty directory %s" % (dir,))                
 
646
        return result
 
647
 
 
648
    def _get_proposed_inventory(self, delta):
 
649
        if len(self.parents):
 
650
            new_inv = self.basis_inventory._get_mutable_inventory()
 
651
        else:
 
652
            new_inv = inventory.Inventory(revision_id=self.revision_id)
 
653
            # This is set in the delta so remove it to prevent a duplicate
 
654
            del new_inv[inventory.ROOT_ID]
 
655
        try:
 
656
            new_inv.apply_delta(delta)
 
657
        except errors.InconsistentDelta:
 
658
            self.mutter("INCONSISTENT DELTA IS:\n%s" % "\n".join([str(de) for de in delta]))
 
659
            raise
 
660
        return new_inv
 
661
 
 
662
    def _add_entry(self, entry):
 
663
        # We need to combine the data if multiple entries have the same file-id.
 
664
        # For example, a rename followed by a modification looks like:
 
665
        #
 
666
        # (x, y, f, e) & (y, y, f, g) => (x, y, f, g)
 
667
        #
 
668
        # Likewise, a modification followed by a rename looks like:
 
669
        #
 
670
        # (x, x, f, e) & (x, y, f, g) => (x, y, f, g)
 
671
        #
 
672
        # Here's a rename followed by a delete and a modification followed by
 
673
        # a delete:
 
674
        #
 
675
        # (x, y, f, e) & (y, None, f, None) => (x, None, f, None)
 
676
        # (x, x, f, e) & (x, None, f, None) => (x, None, f, None)
 
677
        #
 
678
        # In summary, we use the original old-path, new new-path and new ie
 
679
        # when combining entries.
 
680
        old_path = entry[0]
 
681
        new_path = entry[1]
 
682
        file_id = entry[2]
 
683
        ie = entry[3]
 
684
        existing = self._delta_entries_by_fileid.get(file_id, None)
 
685
        if existing is not None:
 
686
            old_path = existing[0]
 
687
            entry = (old_path, new_path, file_id, ie)
 
688
        if new_path is None and old_path is None:
 
689
            # This is a delete cancelling a previous add
 
690
            del self._delta_entries_by_fileid[file_id]
 
691
            parent_dir = osutils.dirname(existing[1])
 
692
            self.mutter("cancelling add of %s with parent %s" % (existing[1], parent_dir))
 
693
            if parent_dir:
 
694
                self._dirs_that_might_become_empty.add(parent_dir)
 
695
            return
 
696
        else:
 
697
            self._delta_entries_by_fileid[file_id] = entry
 
698
 
 
699
        # Collect parent directories that might become empty
 
700
        if new_path is None:
 
701
            # delete
 
702
            parent_dir = osutils.dirname(old_path)
 
703
            # note: no need to check the root
 
704
            if parent_dir:
 
705
                self._dirs_that_might_become_empty.add(parent_dir)
 
706
        elif old_path is not None and old_path != new_path:
 
707
            # rename
 
708
            old_parent_dir = osutils.dirname(old_path)
 
709
            new_parent_dir = osutils.dirname(new_path)
 
710
            if old_parent_dir and old_parent_dir != new_parent_dir:
 
711
                self._dirs_that_might_become_empty.add(old_parent_dir)
 
712
 
 
713
        # Calculate the per-file parents, if not already done
 
714
        if file_id in self.per_file_parents_for_commit:
 
715
            return
 
716
        if old_path is None:
 
717
            # add
 
718
            # If this is a merge, the file was most likely added already.
 
719
            # The per-file parent(s) must therefore be calculated and
 
720
            # we can't assume there are none.
 
721
            per_file_parents, ie.revision = \
 
722
                self.rev_store.get_parents_and_revision_for_entry(ie)
 
723
            self.per_file_parents_for_commit[file_id] = per_file_parents
 
724
        elif new_path is None:
 
725
            # delete
 
726
            pass
 
727
        elif old_path != new_path:
 
728
            # rename
 
729
            per_file_parents, _ = \
 
730
                self.rev_store.get_parents_and_revision_for_entry(ie)
 
731
            self.per_file_parents_for_commit[file_id] = per_file_parents
 
732
        else:
 
733
            # modify
 
734
            per_file_parents, ie.revision = \
 
735
                self.rev_store.get_parents_and_revision_for_entry(ie)
 
736
            self.per_file_parents_for_commit[file_id] = per_file_parents
 
737
 
 
738
    def record_new(self, path, ie):
 
739
        self._add_entry((None, path, ie.file_id, ie))
 
740
 
 
741
    def record_changed(self, path, ie, parent_id=None):
 
742
        self._add_entry((path, path, ie.file_id, ie))
 
743
        self._modified_file_ids[path] = ie.file_id
 
744
 
 
745
    def record_delete(self, path, ie):
 
746
        self._add_entry((path, None, ie.file_id, None))
 
747
        self._paths_deleted_this_commit.add(path)
 
748
        if ie.kind == 'directory':
 
749
            try:
 
750
                del self.directory_entries[path]
 
751
            except KeyError:
 
752
                pass
 
753
            for child_relpath, entry in \
 
754
                self.basis_inventory.iter_entries_by_dir(from_dir=ie):
 
755
                child_path = osutils.pathjoin(path, child_relpath)
 
756
                self._add_entry((child_path, None, entry.file_id, None))
 
757
                self._paths_deleted_this_commit.add(child_path)
 
758
                if entry.kind == 'directory':
 
759
                    try:
 
760
                        del self.directory_entries[child_path]
 
761
                    except KeyError:
 
762
                        pass
 
763
 
 
764
    def record_rename(self, old_path, new_path, file_id, old_ie):
 
765
        new_ie = old_ie.copy()
 
766
        new_basename, new_parent_id = self._ensure_directory(new_path,
 
767
            self.basis_inventory)
 
768
        new_ie.name = new_basename
 
769
        new_ie.parent_id = new_parent_id
 
770
        new_ie.revision = self.revision_id
 
771
        self._add_entry((old_path, new_path, file_id, new_ie))
 
772
        self._modified_file_ids[new_path] = file_id
 
773
        self._paths_deleted_this_commit.discard(new_path)
 
774
        if new_ie.kind == 'directory':
 
775
            self.directory_entries[new_path] = new_ie
 
776
 
 
777
    def _rename_pending_change(self, old_path, new_path, file_id):
 
778
        """Instead of adding/modifying old-path, add new-path instead."""
 
779
        # note: delta entries look like (old, new, file-id, ie)
 
780
        old_ie = self._delta_entries_by_fileid[file_id][3]
 
781
 
 
782
        # Delete the old path. Note that this might trigger implicit
 
783
        # deletion of newly created parents that could now become empty.
 
784
        self.record_delete(old_path, old_ie)
 
785
 
 
786
        # Update the dictionaries used for tracking new file-ids
 
787
        if old_path in self._new_file_ids:
 
788
            del self._new_file_ids[old_path]
 
789
        else:
 
790
            del self._modified_file_ids[old_path]
 
791
        self._new_file_ids[new_path] = file_id
 
792
 
 
793
        # Create the new InventoryEntry
 
794
        kind = old_ie.kind
 
795
        basename, parent_id = self._ensure_directory(new_path,
 
796
            self.basis_inventory)
 
797
        ie = inventory.make_entry(kind, basename, parent_id, file_id)
 
798
        ie.revision = self.revision_id
 
799
        if kind == 'file':
 
800
            ie.executable = old_ie.executable
 
801
            ie.text_sha1 = old_ie.text_sha1
 
802
            ie.text_size = old_ie.text_size
 
803
        elif kind == 'symlink':
 
804
            ie.symlink_target = old_ie.symlink_target
 
805
 
 
806
        # Record it
 
807
        self.record_new(new_path, ie)
 
808
 
 
809
    def modify_handler(self, filecmd):
 
810
        if filecmd.dataref is not None:
 
811
            if filecmd.kind == commands.DIRECTORY_KIND:
 
812
                data = None
 
813
            elif filecmd.kind == commands.TREE_REFERENCE_KIND:
 
814
                data = filecmd.dataref
 
815
            else:
 
816
                data = self.cache_mgr.fetch_blob(filecmd.dataref)
 
817
        else:
 
818
            data = filecmd.data
 
819
        self.debug("modifying %s", filecmd.path)
 
820
        self._modify_item(filecmd.path, filecmd.kind,
 
821
            filecmd.is_executable, data, self.basis_inventory)
 
822
 
 
823
    def delete_handler(self, filecmd):
 
824
        self.debug("deleting %s", filecmd.path)
 
825
        self._delete_item(filecmd.path, self.basis_inventory)
 
826
 
 
827
    def copy_handler(self, filecmd):
 
828
        src_path = filecmd.src_path
 
829
        dest_path = filecmd.dest_path
 
830
        self.debug("copying %s to %s", src_path, dest_path)
 
831
        self._copy_item(src_path, dest_path, self.basis_inventory)
 
832
 
 
833
    def rename_handler(self, filecmd):
 
834
        old_path = filecmd.old_path
 
835
        new_path = filecmd.new_path
 
836
        self.debug("renaming %s to %s", old_path, new_path)
 
837
        self._rename_item(old_path, new_path, self.basis_inventory)
 
838
 
 
839
    def deleteall_handler(self, filecmd):
 
840
        self.debug("deleting all files (and also all directories)")
 
841
        # I'm not 100% sure this will work in the delta case.
 
842
        # But clearing out the basis inventory so that everything
 
843
        # is added sounds ok in theory ...
 
844
        # We grab a copy as the basis is likely to be cached and
 
845
        # we don't want to destroy the cached version
 
846
        self.basis_inventory = copy_inventory(self.basis_inventory)
 
847
        self._delete_all_items(self.basis_inventory)