~bzr-pqm/bzr/bzr.dev

« back to all changes in this revision

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

  • Committer: Andrew Bennetts
  • Date: 2010-10-08 08:15:14 UTC
  • mto: This revision was merged to the branch mainline in revision 5498.
  • Revision ID: andrew.bennetts@canonical.com-20101008081514-dviqzrdfwyzsqbz2
Split NEWS into per-release doc/en/release-notes/bzr-*.txt

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