Rev 5726: (spiv) Add 'changelog_merge' plugin. (Andrew Bennetts) in file:///home/pqm/archives/thelove/bzr/%2Btrunk/

Canonical.com Patch Queue Manager pqm at pqm.ubuntu.com
Wed Mar 16 07:33:08 UTC 2011


At file:///home/pqm/archives/thelove/bzr/%2Btrunk/

------------------------------------------------------------
revno: 5726 [merge]
revision-id: pqm at pqm.ubuntu.com-20110316073305-1r89t4gis3nawye3
parent: pqm at pqm.ubuntu.com-20110315110549-4e8dv1v95wbjbkz7
parent: andrew.bennetts at canonical.com-20110315080704-qrgq855gtmdw8we5
committer: Canonical.com Patch Queue Manager <pqm at pqm.ubuntu.com>
branch nick: +trunk
timestamp: Wed 2011-03-16 07:33:05 +0000
message:
  (spiv) Add 'changelog_merge' plugin. (Andrew Bennetts)
added:
  bzrlib/plugins/changelog_merge/ changelog_merge-20110315075052-dfnfubojfup1895e-1
  bzrlib/plugins/changelog_merge/__init__.py __init__.py-20100920024628-tdrjysnxjm1q96oq-1
  bzrlib/plugins/changelog_merge/changelog_merge.py changelog_merge.py-20100920024628-tdrjysnxjm1q96oq-2
  bzrlib/plugins/changelog_merge/tests/ tests-20110310080015-9c3muqni567c1qux-1
  bzrlib/plugins/changelog_merge/tests/__init__.py __init__.py-20110310080015-9c3muqni567c1qux-2
  bzrlib/plugins/changelog_merge/tests/test_changelog_merge.py test_changelog_merge-20110310080015-9c3muqni567c1qux-3
modified:
  doc/en/release-notes/bzr-2.4.txt bzr2.4.txt-20110114053217-k7ym9jfz243fddjm-1
  doc/en/whats-new/whats-new-in-2.4.txt whatsnewin2.4.txt-20110114044330-nipk1og7j729fy89-1
=== added directory 'bzrlib/plugins/changelog_merge'
=== added file 'bzrlib/plugins/changelog_merge/__init__.py'
--- a/bzrlib/plugins/changelog_merge/__init__.py	1970-01-01 00:00:00 +0000
+++ b/bzrlib/plugins/changelog_merge/__init__.py	2011-03-15 07:56:42 +0000
@@ -0,0 +1,87 @@
+# 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
+
+__doc__ = """Merge hook for GNU-format ChangeLog files
+
+To enable this plugin, add a section to your location.conf
+like::
+
+    [/home/user/proj]
+    changelog_merge_files = ChangeLog
+
+Or add an entry to your branch.conf like::
+
+    changelog_merge_files = ChangeLog
+
+The changelog_merge_files config option takes a list of file names (not paths),
+separated by commas.  (This is unlike the news_merge plugin, which matches
+paths.)  e.g. the above config examples would match both
+``src/foolib/ChangeLog`` and ``docs/ChangeLog``.
+
+The algorithm used to merge the changes can be summarised as:
+
+ * new entries added to the top of OTHER are emitted first
+ * all other additions, deletions and edits from THIS and OTHER are preserved
+ * edits (e.g. to fix typos) at the top of OTHER are hard to distinguish from
+   adding and deleting independent entries; the algorithm tries to guess which
+   based on how similar the old and new entries are.
+
+Caveats
+-------
+
+Most changes can be merged, but conflicts are possible if the plugin finds
+edits at the top of OTHER to entries that have been deleted (or also edited) by
+THIS.  In that case the plugin gives up and bzr's default merge logic will be
+used.
+
+No effort is made to deduplicate entries added by both sides.
+
+The results depend on the choice of the 'base' version, so it might give
+strange results if there is a criss-cross merge.
+"""
+
+# Since we are a built-in plugin we share the bzrlib version
+from bzrlib import version_info
+
+# Put most of the code in a separate module that we lazy-import to keep the
+# overhead of this plugin as minimal as possible.
+from bzrlib.lazy_import import lazy_import
+lazy_import(globals(), """
+from bzrlib.plugins.changelog_merge import changelog_merge as _mod_changelog_merge
+""")
+
+from bzrlib.merge import Merger
+
+
+def changelog_merge_hook(merger):
+    """Merger.merge_file_content hook for GNU-format ChangeLog files."""
+    return _mod_changelog_merge.ChangeLogMerger(merger)
+
+
+def install_hook():
+    Merger.hooks.install_named_hook(
+        'merge_file_content', changelog_merge_hook, 'GNU ChangeLog file merge')
+install_hook()
+
+
+def load_tests(basic_tests, module, loader):
+    testmod_names = [
+        'tests',
+        ]
+    basic_tests.addTest(loader.loadTestsFromModuleNames(
+            ["%s.%s" % (__name__, tmn) for tmn in testmod_names]))
+    return basic_tests
+

=== added file 'bzrlib/plugins/changelog_merge/changelog_merge.py'
--- a/bzrlib/plugins/changelog_merge/changelog_merge.py	1970-01-01 00:00:00 +0000
+++ b/bzrlib/plugins/changelog_merge/changelog_merge.py	2011-03-15 07:54:39 +0000
@@ -0,0 +1,192 @@
+# 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."""
+
+import difflib
+
+from bzrlib import merge
+from bzrlib import debug
+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 tree.inventory[params.file_id].name
+
+    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:
+            return 'not_applicable' # XXX: generating a nice conflict file
+                                    # would be better
+        # 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 = [
+        entry_as_str(entry) for entry in deleted_entries]
+    new_entries_as_strs = [
+        entry_as_str(entry) for entry in 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 = None
+        for new_entry in new_entries:
+            new_entry_as_str = entry_as_str(new_entry)
+            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 > CUTOFF:
+                    if best_score is None or score > best_score:
+                        best = new_entry_as_str, old_entry_as_str
+                        best_score = score
+        if best is not None:
+            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:
+            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

=== added directory 'bzrlib/plugins/changelog_merge/tests'
=== added file 'bzrlib/plugins/changelog_merge/tests/__init__.py'
--- a/bzrlib/plugins/changelog_merge/tests/__init__.py	1970-01-01 00:00:00 +0000
+++ b/bzrlib/plugins/changelog_merge/tests/__init__.py	2011-03-10 08:02:30 +0000
@@ -0,0 +1,24 @@
+# Copyright (C) 2011 by 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
+
+def load_tests(basic_tests, module, loader):
+    testmod_names = [
+        'test_changelog_merge',
+        ]
+    basic_tests.addTest(loader.loadTestsFromModuleNames(
+            ["%s.%s" % (__name__, tmn) for tmn in testmod_names]))
+    return basic_tests
+

=== added file 'bzrlib/plugins/changelog_merge/tests/test_changelog_merge.py'
--- a/bzrlib/plugins/changelog_merge/tests/test_changelog_merge.py	1970-01-01 00:00:00 +0000
+++ b/bzrlib/plugins/changelog_merge/tests/test_changelog_merge.py	2011-03-11 06:20:40 +0000
@@ -0,0 +1,140 @@
+# Copyright (C) 2011 by 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
+
+from bzrlib import (
+    tests,
+    )
+from bzrlib.plugins.changelog_merge import changelog_merge
+
+
+sample_base_entries = [
+    'Base entry B1',
+    'Base entry B2',
+    'Base entry B3',
+    ]
+
+sample_this_entries = [
+    'This entry T1',
+    'This entry T2',
+    #'Base entry B1 updated',
+    'Base entry B1',
+    'Base entry B2',
+    'Base entry B3',
+    ]
+
+sample_other_entries = [
+    'Other entry O1',
+    #'Base entry B1',
+    'Base entry B1',
+    'Base entry B2 updated',
+    'Base entry B3',
+    ]
+
+
+sample2_base_entries = [
+    'Base entry B1',
+    'Base entry B2',
+    'Base entry B3',
+    ]
+
+sample2_this_entries = [
+    'This entry T1',
+    'This entry T2',
+    #'Base entry B1 updated',
+    'Base entry B1',
+    'Base entry B2',
+    ]
+
+sample2_other_entries = [
+    'Other entry O1',
+    #'Base entry B1',
+    'Base entry B1 edit',  # > 80% similar according to difflib
+    'Base entry B2',
+    ]
+
+
+class TestMergeCoreLogic(tests.TestCase):
+
+    def test_new_in_other_floats_to_top(self):
+        """Changes at the top of 'other' float to the top.
+
+        Given a changelog in THIS containing::
+
+          NEW-1
+          OLD-1
+
+        and a changelog in OTHER containing::
+
+          NEW-2
+          OLD-1
+
+        it will merge as::
+
+          NEW-2
+          NEW-1
+          OLD-1
+        """
+        base_entries = ['OLD-1']
+        this_entries = ['NEW-1', 'OLD-1']
+        other_entries = ['NEW-2', 'OLD-1']
+        result_entries = changelog_merge.merge_entries(
+            base_entries, this_entries, other_entries)
+        self.assertEqual(
+            ['NEW-2', 'NEW-1', 'OLD-1'], result_entries)
+
+    def test_acceptance_bug_723968(self):
+        """Merging a branch that:
+
+         1. adds a new entry, and
+         2. edits an old entry (e.g. to fix a typo or twiddle formatting)
+
+        will:
+
+         1. add the new entry to the top
+         2. keep the edit, without duplicating the edited entry or moving it.
+        """
+        result_entries = changelog_merge.merge_entries(
+            sample_base_entries, sample_this_entries, sample_other_entries)
+        self.assertEqual([
+            'Other entry O1',
+            'This entry T1',
+            'This entry T2',
+            'Base entry B1',
+            'Base entry B2 updated',
+            'Base entry B3',
+            ],
+            list(result_entries))
+
+    def test_more_complex_conflict(self):
+        """Like test_acceptance_bug_723968, but with a more difficult conflict:
+        the new entry and the edited entry are adjacent.
+        """
+        def guess_edits(new, deleted):
+            #import pdb; pdb.set_trace()
+            return changelog_merge.default_guess_edits(new, deleted,
+                    entry_as_str=lambda x: x)
+        result_entries = changelog_merge.merge_entries(
+            sample2_base_entries, sample2_this_entries, sample2_other_entries,
+            guess_edits=guess_edits)
+        self.assertEqual([
+            'Other entry O1',
+            'This entry T1',
+            'This entry T2',
+            'Base entry B1 edit',
+            'Base entry B2',
+            ],
+            list(result_entries))
+

=== modified file 'doc/en/release-notes/bzr-2.4.txt'
--- a/doc/en/release-notes/bzr-2.4.txt	2011-03-14 14:21:29 +0000
+++ b/doc/en/release-notes/bzr-2.4.txt	2011-03-15 08:07:04 +0000
@@ -20,12 +20,10 @@
 
 .. New commands, options, etc that users may wish to try out.
 
-* The ``lp:`` directory service now supports Launchpad's QA staging.
-  (Jelmer Vernooij, #667483)
-
-* External merge tools can now be configured in bazaar.conf. See
-  ``bzr help configuration`` for more information.  (Gordon Tyler, #489915)
-
+* Added ``changelog_merge`` plugin for merging changes to ``Changelog`` files
+  in GNU format.  See ``bzr help changelog_merge`` for details.
+  (Andrew Bennetts)
+  
 * Configuration options can now use references to other options in the same
   file by enclosing them with curly brackets (``{other_opt}``). This makes it
   possible to use, for example,
@@ -34,6 +32,12 @@
   this feature. It can be activated by declaring ``bzr.config.expand = True``
   in ``bazaar.conf``. (Vincent Ladeuil)
 
+* External merge tools can now be configured in bazaar.conf. See
+  ``bzr help configuration`` for more information.  (Gordon Tyler, #489915)
+
+* The ``lp:`` directory service now supports Launchpad's QA staging.
+  (Jelmer Vernooij, #667483)
+
 Improvements
 ************
 

=== modified file 'doc/en/whats-new/whats-new-in-2.4.txt'
--- a/doc/en/whats-new/whats-new-in-2.4.txt	2011-02-25 12:12:39 +0000
+++ b/doc/en/whats-new/whats-new-in-2.4.txt	2011-03-15 08:07:04 +0000
@@ -40,6 +40,14 @@
 during the beta period controlled by the ``bzr.config.expand`` option that
 should be declared in ``bazaar.conf`` and no other file.
 
+Changelog merge plugin
+**********************
+
+The ``changelog_merge`` plugin has been added.  It provides a merge hook
+to automate merging of changes to ``ChangeLog`` files in GNU's change log
+format.  Refer to ``bzr help changelog_merge`` for documentation on how to
+enable it and what it can do.
+
 Further information
 *******************
 
@@ -54,3 +62,6 @@
 * :doc:`whats-new-in-2.1`
 * :doc:`whats-new-in-2.2`
 * :doc:`whats-new-in-2.3`
+
+..
+   vim: tw=74 ft=rst ff=unix




More information about the bazaar-commits mailing list