/breezy/unstable

To get this branch, use:
bzr branch https://code.breezy-vcs.org/breezy/unstable

« back to all changes in this revision

Viewing changes to breezy/plugins/fastimport/bzr_commit_handler.py

  • Committer: Jelmer Vernooij
  • Date: 2017-05-24 01:39:33 UTC
  • mfrom: (3815.3776.6)
  • Revision ID: jelmer@jelmer.uk-20170524013933-ir4y4tqtrsiz2ka2
New upstream snapshot.

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