diff options
Diffstat (limited to 'bzrlib/plugins/changelog_merge/changelog_merge.py')
-rw-r--r-- | bzrlib/plugins/changelog_merge/changelog_merge.py | 199 |
1 files changed, 199 insertions, 0 deletions
diff --git a/bzrlib/plugins/changelog_merge/changelog_merge.py b/bzrlib/plugins/changelog_merge/changelog_merge.py new file mode 100644 index 0000000..2b3ce8a --- /dev/null +++ b/bzrlib/plugins/changelog_merge/changelog_merge.py @@ -0,0 +1,199 @@ +# Copyright (C) 2010 Canonical Ltd +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + +"""Merge logic for changelog_merge plugin.""" + +from __future__ import absolute_import + +import difflib + +from bzrlib import ( + debug, + merge, + urlutils, + ) +from bzrlib.merge3 import Merge3 +from bzrlib.trace import mutter + + +def changelog_entries(lines): + """Return a list of changelog entries. + + :param lines: lines of a changelog file. + :returns: list of entries. Each entry is a tuple of lines. + """ + entries = [] + for line in lines: + if line[0] not in (' ', '\t', '\n'): + # new entry + entries.append([line]) + else: + try: + entry = entries[-1] + except IndexError: + # Cope with leading blank lines. + entries.append([]) + entry = entries[-1] + entry.append(line) + return map(tuple, entries) + + +def entries_to_lines(entries): + """Turn a list of entries into a flat iterable of lines.""" + for entry in entries: + for line in entry: + yield line + + +class ChangeLogMerger(merge.ConfigurableFileMerger): + """Merge GNU-format ChangeLog files.""" + + name_prefix = "changelog" + + def get_filepath(self, params, tree): + """Calculate the path to the file in a tree. + + This is overridden to return just the basename, rather than full path, + so that e.g. if the config says ``changelog_merge_files = ChangeLog``, + then all ChangeLog files in the tree will match (not just one in the + root of the tree). + + :param params: A MergeHookParams describing the file to merge + :param tree: a Tree, e.g. self.merger.this_tree. + """ + return urlutils.basename(tree.id2path(params.file_id)) + + def merge_text(self, params): + """Merge changelog changes. + + * new entries from other will float to the top + * edits to older entries are preserved + """ + # Transform files into lists of changelog entries + this_entries = changelog_entries(params.this_lines) + other_entries = changelog_entries(params.other_lines) + base_entries = changelog_entries(params.base_lines) + try: + result_entries = merge_entries( + base_entries, this_entries, other_entries) + except EntryConflict: + # XXX: generating a nice conflict file would be better + return 'not_applicable', None + # Transform the merged elements back into real blocks of lines. + return 'success', entries_to_lines(result_entries) + + +class EntryConflict(Exception): + pass + + +def default_guess_edits(new_entries, deleted_entries, entry_as_str=''.join): + """Default implementation of guess_edits param of merge_entries. + + This algorithm does O(N^2 * logN) SequenceMatcher.ratio() calls, which is + pretty bad, but it shouldn't be used very often. + """ + deleted_entries_as_strs = map(entry_as_str, deleted_entries) + new_entries_as_strs = map(entry_as_str, new_entries) + result_new = list(new_entries) + result_deleted = list(deleted_entries) + result_edits = [] + sm = difflib.SequenceMatcher() + CUTOFF = 0.8 + while True: + best = None + best_score = CUTOFF + # Compare each new entry with each old entry to find the best match + for new_entry_as_str in new_entries_as_strs: + sm.set_seq1(new_entry_as_str) + for old_entry_as_str in deleted_entries_as_strs: + sm.set_seq2(old_entry_as_str) + score = sm.ratio() + if score > best_score: + best = new_entry_as_str, old_entry_as_str + best_score = score + if best is not None: + # Add the best match to the list of edits, and remove it from the + # the list of new/old entries. Also remove it from the new/old + # lists for the next round. + del_index = deleted_entries_as_strs.index(best[1]) + new_index = new_entries_as_strs.index(best[0]) + result_edits.append( + (result_deleted[del_index], result_new[new_index])) + del deleted_entries_as_strs[del_index], result_deleted[del_index] + del new_entries_as_strs[new_index], result_new[new_index] + else: + # No match better than CUTOFF exists in the remaining new and old + # entries. + break + return result_new, result_deleted, result_edits + + +def merge_entries(base_entries, this_entries, other_entries, + guess_edits=default_guess_edits): + """Merge changelog given base, this, and other versions.""" + m3 = Merge3(base_entries, this_entries, other_entries, allow_objects=True) + result_entries = [] + at_top = True + for group in m3.merge_groups(): + if 'changelog_merge' in debug.debug_flags: + mutter('merge group:\n%r', group) + group_kind = group[0] + if group_kind == 'conflict': + _, base, this, other = group + # Find additions + new_in_other = [ + entry for entry in other if entry not in base] + # Find deletions + deleted_in_other = [ + entry for entry in base if entry not in other] + if at_top and deleted_in_other: + # Magic! Compare deletions and additions to try spot edits + new_in_other, deleted_in_other, edits_in_other = guess_edits( + new_in_other, deleted_in_other) + else: + # Changes not made at the top are always preserved as is, no + # need to try distinguish edits from adds and deletes. + edits_in_other = [] + if 'changelog_merge' in debug.debug_flags: + mutter('at_top: %r', at_top) + mutter('new_in_other: %r', new_in_other) + mutter('deleted_in_other: %r', deleted_in_other) + mutter('edits_in_other: %r', edits_in_other) + # Apply deletes and edits + updated_this = [ + entry for entry in this if entry not in deleted_in_other] + for old_entry, new_entry in edits_in_other: + try: + index = updated_this.index(old_entry) + except ValueError: + # edited entry no longer present in this! Just give up and + # declare a conflict. + raise EntryConflict() + updated_this[index] = new_entry + if 'changelog_merge' in debug.debug_flags: + mutter('updated_this: %r', updated_this) + if at_top: + # Float new entries from other to the top + result_entries = new_in_other + result_entries + else: + result_entries.extend(new_in_other) + result_entries.extend(updated_this) + else: # unchanged, same, a, or b. + lines = group[1] + result_entries.extend(lines) + at_top = False + return result_entries |