~bzr-pqm/bzr/bzr.dev

« back to all changes in this revision

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

  • Committer: Tarmac
  • Author(s): Vincent Ladeuil
  • Date: 2017-01-30 14:42:05 UTC
  • mfrom: (6620.1.1 trunk)
  • Revision ID: tarmac-20170130144205-r8fh2xpmiuxyozpv
Merge  2.7 into trunk including fix for bug #1657238 [r=vila]

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