Based on last Anne Mohsen's patch, this fix: - doesn't modify bzrlib at all but use a post_uncommit hook instead, - comes with tests :)
This still doesn't address several points raised in the bug comments that I'd like us to address in the future: - share a common save/restore mechanism with qbzr (which currently implements --fixes and --authors (but doesn't save them at uncommit time, also a single message is saved in qbzr case)), - provide better ways to get at uncommit messages (ideally one can visualize the branch starting at the tip before the commit and just drag and drop from there to build its commit message(s). Final note: feedback and tests before landing and release will be warmly welcomed, there are now automated tests, but I may have missed some use cases... Vincent
# Bazaar merge directive format 2 (Bazaar 0.90) # revision_id: [email protected] # target_branch: bzr+ssh://bazaar.launchpad.net/%7Ebzr-gtk/bzr-\ # gtk/trunk/ # testament_sha1: 6c98a39b3e13f455b71fd0aff8a0ec6b7ddc68d2 # timestamp: 2009-05-28 17:42:10 +0200 # base_revision_id: [email protected]\ # h8uwips9hr8ybx4e # # Begin patch === modified file 'NEWS' --- NEWS 2009-05-27 09:40:13 +0000 +++ NEWS 2009-05-28 15:28:13 +0000 @@ -19,6 +19,10 @@ * Fix gannotate.conf handling. (Vincent Ladeuil, #373157) + * Save commit messages at uncommit time, restore them at + gcommit time. Also allow saving commit messages if the commit + is cancelled. (Anne Mohsen, Vincent Ladeuil, #215674) + * Mark as compatible with bzr 1.13. FEATURES === modified file '__init__.py' --- __init__.py 2009-05-21 20:26:23 +0000 +++ __init__.py 2009-05-28 15:14:14 +0000 @@ -37,6 +37,7 @@ import bzrlib import bzrlib.api from bzrlib import ( + branch, config, errors, ) @@ -138,6 +139,13 @@ plugin_cmds.register_lazy("cmd_%s" % cmd, aliases, "bzrlib.plugins.gtk.commands") +def save_commit_messages(*args): + from bzrlib.plugins.gtk import commit + commit.save_commit_messages(*args) + +branch.Branch.hooks.install_named_hook('post_uncommit', + save_commit_messages, + "Saving commit messages for gcommit") import gettext gettext.install('olive-gtk') === modified file 'commit.py' --- commit.py 2009-04-08 08:06:58 +0000 +++ commit.py 2009-05-28 15:28:13 +0000 @@ -27,8 +27,12 @@ import gobject import pango -from bzrlib import errors, osutils -from bzrlib.trace import mutter +from bzrlib import ( + branch, + errors, + osutils, + trace, + ) from bzrlib.util import bencode from bzrlib.plugins.gtk import _i18n @@ -109,12 +113,10 @@ def __init__(self, wt, selected=None, parent=None): gtk.Dialog.__init__(self, title="Commit to %s" % wt.basedir, - parent=parent, - flags=0, - buttons=(gtk.STOCK_CANCEL, - gtk.RESPONSE_CANCEL)) + parent=parent, flags=0,) + self.connect('delete-event', self._on_delete_window) self._question_dialog = question_dialog - + self.set_type_hint(gtk.gdk.WINDOW_TYPE_HINT_NORMAL) self._wt = wt @@ -124,6 +126,7 @@ self._enable_per_file_commits = True self._commit_all_changes = True self.committed_revision_id = None # Nothing has been committed yet + self._saved_commit_messages_manager = SavedCommitMessagesManager(self._wt, self._wt.branch) self.setup_params() self.construct() @@ -198,19 +201,24 @@ self._basis_tree.lock_read() try: from diff import iter_changes_to_status + saved_file_messages = self._saved_commit_messages_manager.get()[1] for (file_id, real_path, change_type, display_path ) in iter_changes_to_status(self._basis_tree, self._wt): if self._selected and real_path != self._selected: enabled = False else: enabled = True + try: + default_message = saved_file_messages[file_id] + except KeyError: + default_message = '' item_iter = store.append([ file_id, real_path.encode('UTF-8'), enabled, display_path.encode('UTF-8'), change_type, - '', # Initial comment + default_message, # Initial comment ]) if self._selected and enabled: initial_cursor = store.get_path(item_iter) @@ -242,7 +250,7 @@ proxy_obj = bus.get_object('org.freedesktop.NetworkManager', '/org/freedesktop/NetworkManager') except dbus.DBusException: - mutter("networkmanager not available.") + trace.mutter("networkmanager not available.") self._check_local.show() return @@ -254,7 +262,7 @@ except dbus.DBusException, e: # Silently drop errors. While DBus may be # available, NetworkManager doesn't necessarily have to be - mutter("unable to get networkmanager state: %r" % e) + trace.mutter("unable to get networkmanager state: %r" % e) self._check_local.show() def _fill_in_per_file_info(self): @@ -348,6 +356,10 @@ self._hpane.pack2(self._right_pane_table, resize=True, shrink=True) def _construct_action_pane(self): + self._button_cancel = gtk.Button(stock=gtk.STOCK_CANCEL) + self._button_cancel.connect('clicked', self._on_cancel_clicked) + self._button_cancel.show() + self.action_area.pack_end(self._button_cancel) self._button_commit = gtk.Button(_i18n("Comm_it"), use_underline=True) self._button_commit.connect('clicked', self._on_commit_clicked) self._button_commit.set_flags(gtk.CAN_DEFAULT) @@ -549,6 +561,7 @@ scroller.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC) self._global_message_text_view = gtk.TextView() + self._set_global_commit_message(self._saved_commit_messages_manager.get()[0]) self._global_message_text_view.modify_font(pango.FontDescription("Monospace")) scroller.add(self._global_message_text_view) scroller.set_shadow_type(gtk.SHADOW_IN) @@ -655,6 +668,33 @@ return files, [] @show_bzr_error + def _on_cancel_clicked(self, button): + """ Cancel button clicked handler. """ + self._do_cancel() + + @show_bzr_error + def _on_delete_window(self, source, event): + """ Delete window handler. """ + self._do_cancel() + + def _do_cancel(self): + """If requested, saves commit messages when cancelling gcommit; they are re-used by a next gcommit""" + mgr = SavedCommitMessagesManager() + self._saved_commit_messages_manager = mgr + mgr.insert(self._get_global_commit_message(), + self._get_specific_files()[1]) + if mgr.is_not_empty(): # maybe worth saving + response = self._question_dialog( + _i18n('Commit cancelled'), + _i18n('Do you want to save your commit messages ?'), + parent=self) + if response == gtk.RESPONSE_NO: + # save nothing and destroy old comments if any + mgr = SavedCommitMessagesManager() + mgr.save(self._wt, self._wt.branch) + self.response(gtk.RESPONSE_CANCEL) # close window + + @show_bzr_error def _on_commit_clicked(self, button): """ Commit button clicked handler. """ self._do_commit() @@ -718,6 +758,8 @@ specific_files=specific_files, revprops=revprops) self.committed_revision_id = rev_id + # destroy old comments if any + SavedCommitMessagesManager().save(self._wt, self._wt.branch) self.response(gtk.RESPONSE_OK) def _get_global_commit_message(self): @@ -751,3 +793,102 @@ show_offset=False) rev_dict['revision_id'] = rev.revision_id return rev_dict + + +class SavedCommitMessagesManager: + """Save glogal and per-file commit messages. + + Saves global commit message and utf-8 file_id->message dictionary + of per-file commit messages on disk. Re-reads them later for re-using. + """ + + def __init__(self, tree=None, branch=None): + """If branch is None, builds empty messages, otherwise reads them + from branch's disk storage. 'tree' argument is for the future.""" + if branch is None: + self.global_message = u'' + self.file_messages = {} + else: + config = branch.get_config()._get_branch_data_config() + self.global_message = config.get_user_option( + 'gtk_global_commit_message') + if self.global_message is None: + self.global_message = u'' + file_messages = config.get_user_option('gtk_file_commit_messages') + if file_messages: # unicode and B-encoded: + self.file_messages = bencode.bdecode( + file_messages.encode('UTF-8')) + else: + self.file_messages = {} + + def get(self): + return self.global_message, self.file_messages + + def is_not_empty(self): + return bool(self.global_message or self.file_messages) + + def insert(self, global_message, file_info): + """Formats per-file commit messages (list of dictionaries, one per file) + into one utf-8 file_id->message dictionary and merges this with + previously existing dictionary. Merges global commit message too.""" + file_messages = {} + for fi in file_info: + file_message = fi['message'] + if file_message: + file_messages[fi['file_id']] = file_message # utf-8 strings + for k,v in file_messages.iteritems(): + try: + self.file_messages[k] = v + '\n******\n' + self.file_messages[k] + except KeyError: + self.file_messages[k] = v + if self.global_message: + self.global_message = global_message + '\n******\n' \ + + self.global_message + else: + self.global_message = global_message + + def save(self, tree, branch): + # We store in branch's config, which can be a problem if two gcommit + # are done in two checkouts of one single branch (comments overwrite + # each other). Ideally should be in working tree. But uncommit does + # not always have a working tree, though it always has a branch. + # 'tree' argument is for the future + config = branch.get_config() + # should it be named "gtk_" or some more neutral name ("gui_" ?) to + # be compatible with qbzr in the future? + config.set_user_option('gtk_global_commit_message', self.global_message) + # bencode() does not know unicode objects but set_user_option() + # requires one: + config.set_user_option( + 'gtk_file_commit_messages', + bencode.bencode(self.file_messages).decode('UTF-8')) + + +def save_commit_messages(local, master, old_revno, old_revid, + new_revno, new_revid): + b = local + if b is None: + b = master + mgr = SavedCommitMessagesManager(None, b) + revid_iterator = b.repository.iter_reverse_revision_history(old_revid) + cur_revno = old_revno + new_revision_id = old_revid + graph = b.repository.get_graph() + for rev_id in revid_iterator: + if cur_revno == new_revno: + break + cur_revno -= 1 + rev = b.repository.get_revision(rev_id) + file_info = rev.properties.get('file-info', None) + if file_info is None: + file_info = {} + else: + file_info = bencode.bdecode(file_info.encode('UTF-8')) + global_message = osutils.safe_unicode(rev.message) + # Concatenate comment of the uncommitted revision + mgr.insert(global_message, file_info) + + parents = graph.get_parent_map([rev_id]).get(rev_id, None) + if not parents: + continue + mgr.save(None, b) === modified file 'tests/test_commit.py' --- tests/test_commit.py 2008-11-13 06:55:41 +0000 +++ tests/test_commit.py 2009-05-28 15:14:14 +0000 @@ -21,8 +21,10 @@ import gtk from bzrlib import ( + branch, + revision, tests, - revision, + uncommit, ) from bzrlib.util import bencode @@ -667,8 +669,7 @@ ]), dlg._get_specific_files()) -class TestCommitDialog_Commit(tests.TestCaseWithTransport): - """Tests on the actual 'commit' button being pushed.""" +class QuestionHelpers(object): def _set_question_yes(self, dlg): """Set the dialog to answer YES to any questions.""" @@ -688,6 +689,10 @@ return gtk.RESPONSE_NO dlg._question_dialog = _question_no + +class TestCommitDialog_Commit(tests.TestCaseWithTransport, QuestionHelpers): + """Tests on the actual 'commit' button being pushed.""" + def test_bound_commit_local(self): tree = self.make_branch_and_tree('tree') self.build_tree(['tree/a']) @@ -1086,3 +1091,134 @@ def test_converts_mixed_to_lf(self): self.assertSanitize('foo\nbar\nbaz\n', 'foo\r\nbar\rbaz\n') + + +class TestSavedCommitMessages(tests.TestCaseWithTransport): + + def setUp(self): + super(TestSavedCommitMessages, self).setUp() + # Install our hook + branch.Branch.hooks.install_named_hook( + 'post_uncommit', commit.save_commit_messages, None) + + def _get_file_info_dict(self, rank): + file_info = [dict(path='a', file_id='a-id', message='a msg %d' % rank), + dict(path='b', file_id='b-id', message='b msg %d' % rank)] + return file_info + + def _get_file_info_revprops(self, rank): + file_info_prop = self._get_file_info_dict(rank) + return {'file-info': bencode.bencode(file_info_prop).decode('UTF-8')} + + def _get_commit_message(self): + return self.config.get_user_option('gtk_global_commit_message') + + def _get_file_commit_messages(self): + return self.config.get_user_option('gtk_file_commit_messages') + + +class TestUncommitHook(TestSavedCommitMessages): + + def setUp(self): + super(TestUncommitHook, self).setUp() + self.tree = self.make_branch_and_tree('tree') + self.config = self.tree.branch.get_config() + self.build_tree(['tree/a', 'tree/b']) + self.tree.add(['a'], ['a-id']) + self.tree.add(['b'], ['b-id']) + rev1 = self.tree.commit('one', rev_id='one-id', + revprops=self._get_file_info_revprops(1)) + rev2 = self.tree.commit('two', rev_id='two-id', + revprops=self._get_file_info_revprops(2)) + rev3 = self.tree.commit('three', rev_id='three-id', + revprops=self._get_file_info_revprops(3)) + + def test_uncommit_one_by_one(self): + uncommit.uncommit(self.tree.branch, tree=self.tree) + self.assertEquals(u'three', self._get_commit_message()) + self.assertEquals(u'd4:a-id7:a msg 34:b-id7:b msg 3e', + self._get_file_commit_messages()) + + uncommit.uncommit(self.tree.branch, tree=self.tree) + self.assertEquals(u'two\n******\nthree', self._get_commit_message()) + self.assertEquals(u'd4:a-id22:a msg 2\n******\na msg 3' + '4:b-id22:b msg 2\n******\nb msg 3e', + self._get_file_commit_messages()) + + uncommit.uncommit(self.tree.branch, tree=self.tree) + self.assertEquals(u'one\n******\ntwo\n******\nthree', + self._get_commit_message()) + self.assertEquals(u'd4:a-id37:a msg 1\n******\na msg 2\n******\na msg 3' + '4:b-id37:b msg 1\n******\nb msg 2\n******\nb msg 3e', + self._get_file_commit_messages()) + + def test_uncommit_all_at_once(self): + uncommit.uncommit(self.tree.branch, tree=self.tree, revno=1) + self.assertEquals(u'one\n******\ntwo\n******\nthree', + self._get_commit_message()) + self.assertEquals(u'd4:a-id37:a msg 1\n******\na msg 2\n******\na msg 3' + '4:b-id37:b msg 1\n******\nb msg 2\n******\nb msg 3e', + self._get_file_commit_messages()) + + +class TestReusingSavedCommitMessages(TestSavedCommitMessages, QuestionHelpers): + + def setUp(self): + super(TestReusingSavedCommitMessages, self).setUp() + self.tree = self.make_branch_and_tree('tree') + self.config = self.tree.branch.get_config() + self.config.set_user_option('per_file_commits', 'true') + self.build_tree(['tree/a', 'tree/b']) + self.tree.add(['a'], ['a-id']) + self.tree.add(['b'], ['b-id']) + rev1 = self.tree.commit('one', revprops=self._get_file_info_revprops(1)) + rev2 = self.tree.commit('two', revprops=self._get_file_info_revprops(2)) + uncommit.uncommit(self.tree.branch, tree=self.tree) + self.build_tree_contents([('tree/a', 'new a content\n'), + ('tree/b', 'new b content'),]) + + def _get_commit_dialog(self, tree): + # Ensure we will never use a dialog that can actually prompt the user + # during the test suite. Test *can* and *should* override with the + # correct question dialog type. + dlg = commit.CommitDialog(tree) + self._set_question_no(dlg) + return dlg + + def test_setup_saved_messages(self): + # Check the initial setup + self.assertEquals(u'two', self._get_commit_message()) + self.assertEquals(u'd4:a-id7:a msg 24:b-id7:b msg 2e', + self._get_file_commit_messages()) + + def test_messages_are_reloaded(self): + dlg = self._get_commit_dialog(self.tree) + self.assertEquals(u'two', dlg._get_global_commit_message()) + self.assertEquals(([u'a', u'b'], + [{ 'path': 'a', + 'file_id': 'a-id', 'message': 'a msg 2',}, + {'path': 'b', + 'file_id': 'b-id', 'message': 'b msg 2',}],), + dlg._get_specific_files()) + + def test_messages_are_consumed(self): + dlg = self._get_commit_dialog(self.tree) + dlg._do_commit() + self.assertEquals(u'', self._get_commit_message()) + self.assertEquals(u'de', self._get_file_commit_messages()) + + def test_messages_are_saved_on_cancel_if_required(self): + dlg = self._get_commit_dialog(self.tree) + self._set_question_yes(dlg) # Save messages + dlg._do_cancel() + self.assertEquals(u'two', self._get_commit_message()) + self.assertEquals(u'd4:a-id7:a msg 24:b-id7:b msg 2e', + self._get_file_commit_messages()) + + def test_messages_are_cleared_on_cancel_if_required(self): + dlg = self._get_commit_dialog(self.tree) + self._set_question_no(dlg) # Don't save messages + dlg._do_cancel() + self.assertEquals(u'', self._get_commit_message()) + self.assertEquals(u'de', + self._get_file_commit_messages()) # Begin bundle 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 9SxLsxB5+xwUt5Ebv8IR1R/xdyRThQkL8MaxcA==
-- bzr-gtk mailing list [email protected] Modify settings or unsubscribe at: https://lists.canonical.com/mailman/listinfo/bzr-gtk
