~mbp/bzr/764108-diffoptions

« back to all changes in this revision

Viewing changes to bzrlib/plugins/changelog_merge/changelog_merge.py

  • Committer: Canonical.com Patch Queue Manager
  • Date: 2011-03-16 07:33:05 UTC
  • mfrom: (5724.2.3 add-changelog-merge)
  • Revision ID: pqm@pqm.ubuntu.com-20110316073305-1r89t4gis3nawye3
(spiv) Add 'changelog_merge' plugin. (Andrew Bennetts)

Show diffs side-by-side

added added

removed removed

Lines of Context:
 
1
# Copyright (C) 2010 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., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
 
16
 
 
17
"""Merge logic for changelog_merge plugin."""
 
18
 
 
19
import difflib
 
20
 
 
21
from bzrlib import merge
 
22
from bzrlib import debug
 
23
from bzrlib.merge3 import Merge3
 
24
from bzrlib.trace import mutter
 
25
 
 
26
 
 
27
def changelog_entries(lines):
 
28
    """Return a list of changelog entries.
 
29
 
 
30
    :param lines: lines of a changelog file.
 
31
    :returns: list of entries.  Each entry is a tuple of lines.
 
32
    """
 
33
    entries = []
 
34
    for line in lines:
 
35
        if line[0] not in (' ', '\t', '\n'):
 
36
            # new entry
 
37
            entries.append([line])
 
38
        else:
 
39
            try:
 
40
                entry = entries[-1]
 
41
            except IndexError:
 
42
                # Cope with leading blank lines.
 
43
                entries.append([])
 
44
                entry = entries[-1]
 
45
            entry.append(line)
 
46
    return map(tuple, entries)
 
47
 
 
48
 
 
49
def entries_to_lines(entries):
 
50
    """Turn a list of entries into a flat iterable of lines."""
 
51
    for entry in entries:
 
52
        for line in entry:
 
53
            yield line
 
54
 
 
55
 
 
56
class ChangeLogMerger(merge.ConfigurableFileMerger):
 
57
    """Merge GNU-format ChangeLog files."""
 
58
 
 
59
    name_prefix = "changelog"
 
60
 
 
61
    def get_filepath(self, params, tree):
 
62
        """Calculate the path to the file in a tree.
 
63
 
 
64
        This is overridden to return just the basename, rather than full path,
 
65
        so that e.g. if the config says ``changelog_merge_files = ChangeLog``,
 
66
        then all ChangeLog files in the tree will match (not just one in the
 
67
        root of the tree).
 
68
        
 
69
        :param params: A MergeHookParams describing the file to merge
 
70
        :param tree: a Tree, e.g. self.merger.this_tree.
 
71
        """
 
72
        return tree.inventory[params.file_id].name
 
73
 
 
74
    def merge_text(self, params):
 
75
        """Merge changelog changes.
 
76
 
 
77
         * new entries from other will float to the top
 
78
         * edits to older entries are preserved
 
79
        """
 
80
        # Transform files into lists of changelog entries
 
81
        this_entries = changelog_entries(params.this_lines)
 
82
        other_entries = changelog_entries(params.other_lines)
 
83
        base_entries = changelog_entries(params.base_lines)
 
84
        try:
 
85
            result_entries = merge_entries(
 
86
                base_entries, this_entries, other_entries)
 
87
        except EntryConflict:
 
88
            return 'not_applicable' # XXX: generating a nice conflict file
 
89
                                    # would be better
 
90
        # Transform the merged elements back into real blocks of lines.
 
91
        return 'success', entries_to_lines(result_entries)
 
92
 
 
93
 
 
94
class EntryConflict(Exception):
 
95
    pass
 
96
 
 
97
 
 
98
def default_guess_edits(new_entries, deleted_entries, entry_as_str=''.join):
 
99
    """Default implementation of guess_edits param of merge_entries.
 
100
 
 
101
    This algorithm does O(N^2 * logN) SequenceMatcher.ratio() calls, which is
 
102
    pretty bad, but it shouldn't be used very often.
 
103
    """
 
104
    deleted_entries_as_strs = [
 
105
        entry_as_str(entry) for entry in deleted_entries]
 
106
    new_entries_as_strs = [
 
107
        entry_as_str(entry) for entry in new_entries]
 
108
    result_new = list(new_entries)
 
109
    result_deleted = list(deleted_entries)
 
110
    result_edits = []
 
111
    sm = difflib.SequenceMatcher()
 
112
    CUTOFF = 0.8
 
113
    while True:
 
114
        best = None
 
115
        best_score = None
 
116
        for new_entry in new_entries:
 
117
            new_entry_as_str = entry_as_str(new_entry)
 
118
            sm.set_seq1(new_entry_as_str)
 
119
            for old_entry_as_str in deleted_entries_as_strs:
 
120
                sm.set_seq2(old_entry_as_str)
 
121
                score = sm.ratio()
 
122
                if score > CUTOFF:
 
123
                    if best_score is None or score > best_score:
 
124
                        best = new_entry_as_str, old_entry_as_str
 
125
                        best_score = score
 
126
        if best is not None:
 
127
            del_index = deleted_entries_as_strs.index(best[1])
 
128
            new_index = new_entries_as_strs.index(best[0])
 
129
            result_edits.append(
 
130
                (result_deleted[del_index], result_new[new_index]))
 
131
            del deleted_entries_as_strs[del_index], result_deleted[del_index]
 
132
            del new_entries_as_strs[new_index], result_new[new_index]
 
133
        else:
 
134
            break
 
135
    return result_new, result_deleted, result_edits
 
136
 
 
137
 
 
138
def merge_entries(base_entries, this_entries, other_entries,
 
139
        guess_edits=default_guess_edits):
 
140
    """Merge changelog given base, this, and other versions."""
 
141
    m3 = Merge3(base_entries, this_entries, other_entries, allow_objects=True)
 
142
    result_entries = []
 
143
    at_top = True
 
144
    for group in m3.merge_groups():
 
145
        if 'changelog_merge' in debug.debug_flags:
 
146
            mutter('merge group:\n%r', group)
 
147
        group_kind = group[0]
 
148
        if group_kind == 'conflict':
 
149
            _, base, this, other = group
 
150
            # Find additions
 
151
            new_in_other = [
 
152
                entry for entry in other if entry not in base]
 
153
            # Find deletions
 
154
            deleted_in_other = [
 
155
                entry for entry in base if entry not in other]
 
156
            if at_top and deleted_in_other:
 
157
                # Magic!  Compare deletions and additions to try spot edits
 
158
                new_in_other, deleted_in_other, edits_in_other = guess_edits(
 
159
                    new_in_other, deleted_in_other)
 
160
            else:
 
161
                # Changes not made at the top are always preserved as is, no
 
162
                # need to try distinguish edits from adds and deletes.
 
163
                edits_in_other = []
 
164
            if 'changelog_merge' in debug.debug_flags:
 
165
                mutter('at_top: %r', at_top)
 
166
                mutter('new_in_other: %r', new_in_other)
 
167
                mutter('deleted_in_other: %r', deleted_in_other)
 
168
                mutter('edits_in_other: %r', edits_in_other)
 
169
            # Apply deletes and edits
 
170
            updated_this = [
 
171
                entry for entry in this if entry not in deleted_in_other]
 
172
            for old_entry, new_entry in edits_in_other:
 
173
                try:
 
174
                    index = updated_this.index(old_entry)
 
175
                except ValueError:
 
176
                    # edited entry no longer present in this!  Just give up and
 
177
                    # declare a conflict.
 
178
                    raise EntryConflict()
 
179
                updated_this[index] = new_entry
 
180
            if 'changelog_merge' in debug.debug_flags:
 
181
                mutter('updated_this: %r', updated_this)
 
182
            if at_top:
 
183
                # Float new entries from other to the top
 
184
                result_entries = new_in_other + result_entries
 
185
            else:
 
186
                result_entries.extend(new_in_other)
 
187
            result_entries.extend(updated_this)
 
188
        else: # unchanged, same, a, or b.
 
189
            lines = group[1]
 
190
            result_entries.extend(lines)
 
191
        at_top = False
 
192
    return result_entries