Support for tags

Gustavo Niemeyer gustavo at niemeyer.net
Sat Oct 8 04:44:34 BST 2005


Greetings!

I'm sad to announce that today is the last day I'm working
full-time in bzr. On monday I'll be moving on to another
project. I really hope to be able to keep contributing to bzr
as time permits, though.

I'd like to thank everyone for the great support in those
three weeks, specially Robert, Aaron, and Martin.

Anyway.. let's talk about the message subject a bit. :-)

The attached patch implements full support for symbolic tags
in bzr. I'll present below details about what has been done.

Ah, before you start reading, if you have superpowers to
merge that code in, *please* don't wait too long before doing
so. This is a large patch, which probably won't apply anymore
tomorrow. :-) There is a good number of tests for the new
code introduced.

So, here we go...

- Tags are human-meaningful nicknames for given revisions.

- Tags are versioned. Whenever you add or remove a tag, the
  action is added to a "pending actions" queue, which will
  become part of the revision at commit time.

- Tag changes are merged using three-way logic. In case a
  tag conflicts with a local change, the local change is
  preserved and a warning message is issued.

- Tags are unique inside a given branch.

- Multiple tags inside a given branch may point to the same
  revision id.

- There are two new classes: Tags and PendingActions.

- The Tags class manages tags in memory, and recreate the
  text for storing them in the weave.

- The tags text is a list of sorted lines with <tag>:<revision>
  They're sorted so that weaves will delta them correctly.
  
- The PendingActions class is a simple and generic
  infrastructure to handle pending actions, like tag insertions
  and removals. I'd really like to port pending merges to that
  infrastructure once the patch gets in.

- branch.pending_actions holds an instance of the PendingActions
  class, which will create .bzr/pending-actions while there are
  pending actions to be committed, and remove it when there are
  none.

- branch.get_revision_tags(rev_id) returns an instance of the Tags
  class for the pristine tags in the given revision (pending
  actions are not considered). The returned instance is
  readonly.

- branch.get_tags() returns an instance of the Tags class with
  the current tags, which are the prstine tags for the last revision
  plus the pending actions. Also readonly.

- branch.set_tag(tag, rev_id) will add a pending action to set the
  tag, and branch.remove_tag(tag) will add a pending action to remove
  the tag. Both methods will do the right thing when 'tag' is already
  mentioned in some pending action. They will also raise TagExists and
  NoSuchTag as necessary.

- There are three new commands: set-tag, remove-tag and show-tags.
  They do what they're supposed to do. :-) In particular, show-tags
  will also show pending actions for tags, and set-tag without a
  -r parameter will tag the current/to-be-committed revision.

- "bzr status" will show pending actions as well.

Have fun! ;-)

-- 
Gustavo Niemeyer
http://niemeyer.net
-------------- next part --------------
=== added file 'bzrlib/pendingactions.py'
--- /dev/null
+++ bzrlib/pendingactions.py
@@ -0,0 +1,130 @@
+#
+# Copyright (C) 2005 Canonical Ltd
+#
+# Written by Gustavo Niemeyer <gustavo at niemeyer.net>
+#
+# 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., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
+#
+from cStringIO import StringIO
+import csv
+
+from bzrlib.errors import TransportError
+
+class PendingActions(object):
+    """
+    Manager for pending actions. A pending action is seen as a tuple,
+    composed by the the action kind, and one or more optional parameters.
+
+    Some examples of possible actions are:
+
+        'set-tag', tag, revision_id
+        'remove-tag', tag
+        'merge', revision_id
+
+    When consulting or removing tags, elements with value None may be
+    provided as wildcards, meaning that any value in that position will
+    be considered as a match.
+    """
+
+    def __init__(self, transport, relpath):
+        self._transport = transport
+        self._relpath = relpath
+
+    def _get_file(self):
+        try:
+            return self._transport.get(self._relpath)
+        except TransportError, e:
+            return None
+
+    def __iter__(self):
+        file = self._get_file()
+        if file:
+            reader = csv.reader(file)
+            for row in reader:
+                yield tuple(row)
+
+    def __nonzero__(self):
+        return self._transport.has(self._relpath)
+
+    def _match(self, row, action):
+        row_len = len(row)
+        if (row_len != len(action) or
+            row[0] != action[0] and action[0] != None):
+            return False
+        for i in range(1,row_len):
+            if row[i] != action[i] and action[i] != None:
+                return False
+        return True
+
+    def __contains__(self, action):
+        for row in self:
+            if self._match(row, action):
+                return True
+        return False
+
+    def iter(self, kind, *args):
+        action = (kind,)+args
+        for row in self:
+            if self._match(row, action):
+                yield row
+
+    def add(self, kind, *args):
+        action = (kind,)+args
+        sio = StringIO()
+        writer = csv.writer(sio)
+        for row in self:
+            writer.writerow(row)
+        writer.writerow(action)
+        self._transport.put(self._relpath, sio.getvalue())
+
+    def add_unique(self, kind, *args):
+        action = (kind,)+args
+        sio = StringIO()
+        writer = csv.writer(sio)
+        for row in self:
+            if row == action:
+                return False
+            writer.writerow(row)
+        writer.writerow(action)
+        self._transport.put(self._relpath, sio.getvalue())
+        return True
+
+    def remove(self, kind, *args):
+        action = (kind,)+args
+        sio = StringIO()
+        writer = csv.writer(sio)
+        changed = False
+        for row in self:
+            if row != action and not self._match(row, action):
+                writer.writerow(row)
+            else:
+                changed = True
+        if changed:
+            value = sio.getvalue()
+            if value:
+                self._transport.put(self._relpath, sio.getvalue())
+            else:
+                try:
+                    self._transport.delete(self._relpath)
+                except TransportError:
+                    pass
+        return changed
+
+    def remove_all(self):
+        try:
+            self._transport.delete(self._relpath)
+        except TransportError:
+            pass
+

=== added file 'bzrlib/selftest/testpendingactions.py'
--- /dev/null
+++ bzrlib/selftest/testpendingactions.py
@@ -0,0 +1,143 @@
+from bzrlib.transport.local import LocalTransport
+from bzrlib.selftest import TestCaseInTempDir
+from bzrlib.pendingactions import *
+
+class TestPendingActions(TestCaseInTempDir):
+
+    def setUp(self):
+        TestCaseInTempDir.setUp(self)
+        self.transport = LocalTransport('.')
+        self.relpath = "pending-actions"
+        self.pa = PendingActions(self.transport, self.relpath)
+
+    def tearDown(self):
+        TestCaseInTempDir.tearDown(self)
+        if self.transport.has(self.relpath):
+            self.transport.delete(self.relpath)
+
+    def test_empty(self):
+        """No file means no pending actions"""
+        self.assertFalse(self.pa)
+        self.assertEquals(list(self.pa), [])
+        self.assertFalse(self.transport.has(self.relpath))
+
+    def test_nonempty(self):
+        """No file means no pending actions"""
+        self.pa.add("kind1", "arg1")
+        self.assertTrue(self.pa)
+        self.assertTrue(self.transport.has(self.relpath))
+
+    def test_add(self):
+        """Adding actions preserve order and allow repetitions"""
+        self.pa.add("kind2", "arg1", "arg2")
+        self.pa.add("kind1", "arg3")
+        self.pa.add("kind2", "arg1", "arg2")
+        self.pa.add("kind2", "arg4")
+        self.assertEquals(list(self.pa), [("kind2", "arg1", "arg2"),
+                                          ("kind1", "arg3"),
+                                          ("kind2", "arg1", "arg2"),
+                                          ("kind2", "arg4")])
+
+    def test_contains(self):
+        """Adding actions preserve order and allow repetitions"""
+        self.pa.add("kind1", "arg1")
+        self.pa.add("kind2", "arg2")
+        self.pa.add("kind3")
+        self.assertTrue(("kind1", "arg1") in self.pa)
+        self.assertTrue(("kind2", "arg2") in self.pa)
+        self.assertTrue(("kind3",) in self.pa)
+        self.assertFalse(("kind1", "arg2") in self.pa)
+        self.assertFalse(("kind2", "arg1") in self.pa)
+        self.assertFalse(("kind2",) in self.pa)
+        self.assertFalse(("kind3", "arg1") in self.pa)
+
+    def test_add_unique(self):
+        """Adding unique actions preserve order and do not allow repetitions"""
+        self.assertTrue(self.pa.add_unique("kind2", "arg1", "arg2"))
+        self.assertTrue(self.pa.add_unique("kind1", "arg3"))
+        self.assertFalse(self.pa.add_unique("kind2", "arg1", "arg2"))
+        self.assertTrue(self.pa.add_unique("kind2", "arg4"))
+        self.assertEquals(list(self.pa), [("kind2", "arg1", "arg2"),
+                                          ("kind1", "arg3"),
+                                          ("kind2", "arg4")])
+
+    def test_remove_everything(self):
+        """Remove everything, one by one"""
+        self.pa.add("kind1", "arg1")
+        self.pa.add("kind2", "arg2")
+        self.pa.remove("kind2", "arg2")
+        self.pa.remove("kind1", "arg1")
+        self.assertFalse(self.pa)
+        self.assertEquals(list(self.pa), [])
+        self.assertFalse(self.transport.has(self.relpath))
+
+    def test_remove_all(self):
+        """Remove everything, at once"""
+        self.pa.add("kind1", "arg1")
+        self.pa.add("kind2", "arg2")
+        self.pa.remove_all()
+        self.assertFalse(self.pa)
+        self.assertEquals(list(self.pa), [])
+        self.assertFalse(self.transport.has(self.relpath))
+
+    def test_remove_nothing(self):
+        """remove should return false when it does nothing"""
+        self.pa.add("kind1", "arg1")
+        self.pa.add("kind2", "arg2")
+        self.assertFalse(self.pa.remove("kind3", "arg1"))
+        self.assertFalse(self.pa.remove("kind1", "arg3"))
+
+    def test_remove_something(self):
+        """remove should return true when it does something"""
+        self.pa.add("kind1", "arg1")
+        self.pa.add("kind2", "arg2")
+        self.assertTrue(self.pa.remove("kind1", "arg1"))
+        self.assertTrue(self.pa.remove("kind2", "arg2"))
+
+    def test_remove_twice(self):
+        """Removing twice should return true and false"""
+        self.pa.add("kind1", "arg1")
+        self.pa.add("kind2", "arg2")
+        self.assertTrue(self.pa.remove("kind1", "arg1"))
+        self.assertFalse(self.pa.remove("kind1", "arg1"))
+
+    def test_remove_match(self):
+        """Remove using matches should remove the right entries"""
+        self.pa.add("kind1", "arg1", "arg2")
+        self.pa.add("kind2", "arg3")
+        self.pa.add("kind2", "arg3", "arg4")
+        self.pa.add("kind2")
+        self.pa.add("kind2", "arg4")
+        self.pa.add("kind3", "arg5", "arg6")
+        self.assertFalse(self.pa.remove(None, None, None, None))
+        self.assertFalse(self.pa.remove("kind4", None))
+        self.assertFalse(self.pa.remove("kind1", None))
+        self.assertTrue(self.pa.remove("kind2", None))
+        self.assertFalse(self.pa.remove("kind2", None))
+        self.assertTrue(self.pa.remove(None, "arg5", None))
+        self.assertFalse(self.pa.remove(None, "arg5", None))
+        self.assertEquals(list(self.pa), [("kind1", "arg1", "arg2"),
+                                          ("kind2", "arg3", "arg4"),
+                                          ("kind2",)])
+
+    def test_iter(self):
+        """Iterating using matches should iterate through the right entries"""
+        self.pa.add("kind1", "arg1", "arg2")
+        self.pa.add("kind2", "arg3")
+        self.pa.add("kind2", "arg3", "arg4")
+        self.pa.add("kind2")
+        self.pa.add("kind2", "arg4")
+        self.pa.add("kind3", "arg5", "arg6")
+
+        self.assertEquals(list(self.pa.iter(None, None, None, None)), [])
+        self.assertEquals(list(self.pa.iter("kind4", None)), [])
+        self.assertEquals(list(self.pa.iter("kind1", None)), [])
+        self.assertEquals(list(self.pa.iter("kind2", None)),
+                          [("kind2", "arg3"), ("kind2", "arg4")])
+        self.assertEquals(list(self.pa.iter(None, "arg5", None)),
+                          [("kind3", "arg5", "arg6")])
+
+    def test_remove_all_empty(self):
+        """Removing all when empty should be ok"""
+        self.pa.remove_all()
+        self.assertFalse(self.pa)

=== added file 'bzrlib/selftest/testtags.py'
--- /dev/null
+++ bzrlib/selftest/testtags.py
@@ -0,0 +1,240 @@
+from bzrlib.selftest import TestCase, TestCaseInTempDir
+from bzrlib.tags import *
+
+class TestTags(TestCase):
+
+    def setUp(self):
+        TestCase.setUp(self)
+        self.t = Tags()
+
+    def tearDown(self):
+        TestCase.tearDown(self)
+        self.t = None
+
+    def test_set_get(self):
+        """Set and get a couple of tags"""
+        self.t.set("tag1", "rev1")
+        self.t.set("tag2", "rev2")
+        self.assertEquals(self.t.get("tag1"), "rev1")
+        self.assertEquals(self.t.get("tag2"), "rev2")
+
+    def test_set_get_map(self):
+        """Set and get a couple of tags with mapping interface"""
+        self.t["tag1"] = "rev1"
+        self.t["tag2"] = "rev2"
+        self.assertEquals(self.t["tag1"], "rev1")
+        self.assertEquals(self.t["tag2"], "rev2")
+
+    def test_get_unexistent(self):
+        """Getting an unexistent tag should raise an error"""
+        self.assertRaises(NoSuchTag, self.t.get, "tag1")
+
+    def test_get_unexistent_map(self):
+        """Getting an unexistent tag should raise an error"""
+        self.assertRaises(NoSuchTag, self.t.__getitem__, "tag1")
+
+    def test_set_existent(self):
+        """Setting an existent tag should raise an error"""
+        self.t.set("tag1", "rev1")
+        self.assertRaises(TagExists, self.t.set, "tag1", "rev1")
+
+    def test_set_existent_mapping(self):
+        """Setting an existent tag should raise an error"""
+        self.t.set("tag1", "rev1")
+        self.assertRaises(TagExists, self.t.__setitem__, "tag1", "rev1")
+
+    def test_iter(self):
+        """Test iteration"""
+        self.t.set("tag1", "rev1")
+        self.t.set("tag4", "rev4")
+        self.t.set("tag3", "rev3")
+        self.t.set("tag2", "rev2")
+        self.assertEquals(list(self.t), [("tag1", "rev1"), ("tag2", "rev2"),
+                                         ("tag3", "rev3"), ("tag4", "rev4")])
+    
+    def test_merge_adding(self):
+        """Non-conflicting merge without base adding new tags"""
+        self.t.set("tag1", "rev1")
+        self.t.set("tag2", "rev2")
+        other = Tags()
+        other.set("tag3", "rev3")
+        other.set("tag4", "rev4")
+        self.t.merge(other)
+        self.assertEquals(list(self.t), [("tag1", "rev1"), ("tag2", "rev2"),
+                                         ("tag3", "rev3"), ("tag4", "rev4")])
+
+    def test_merge_matching(self):
+        """Non-conflicting merge without base where the two instances match"""
+        self.t.set("tag1", "rev1")
+        self.t.set("tag2", "rev2")
+        other = Tags()
+        other.set("tag1", "rev1")
+        other.set("tag2", "rev2")
+        self.t.merge(other)
+        self.assertEquals(list(self.t), [("tag1", "rev1"), ("tag2", "rev2")])
+
+    def test_merge_base_adding(self):
+        """Non-conflicting merging with base adding new tags"""
+        self.t.set("tag1", "rev1")
+        self.t.set("tag2", "rev2")
+        base = Tags()
+        base.set("tag1", "rev1")
+        base.set("tag2", "rev2")
+        other = Tags()
+        other.set("tag1", "rev1")
+        other.set("tag2", "rev2")
+        other.set("tag3", "rev3")
+        other.set("tag4", "rev4")
+        self.t.merge(other, base)
+        self.assertEquals(list(self.t), [("tag1", "rev1"), ("tag2", "rev2"),
+                                         ("tag3", "rev3"), ("tag4", "rev4")])
+
+    def test_merge_base_matching(self):
+        """Non-conflicting merging with base where instances match"""
+        self.t.set("tag1", "rev1")
+        self.t.set("tag2", "rev2")
+        base = Tags()
+        base.set("tag1", "rev1")
+        base.set("tag2", "rev2")
+        other = Tags()
+        other.set("tag1", "rev1")
+        other.set("tag2", "rev2")
+        self.t.merge(other, base)
+        self.assertEquals(list(self.t), [("tag1", "rev1"), ("tag2", "rev2")])
+
+    def test_merge_base_removing(self):
+        """Non-conflicting merging with base where tags are removed"""
+        self.t.set("tag1", "rev1")
+        self.t.set("tag2", "rev2")
+        base = Tags()
+        base.set("tag1", "rev1")
+        base.set("tag2", "rev2")
+        other = Tags()
+        other.set("tag1", "rev1")
+        self.t.merge(other, base)
+        self.assertEquals(list(self.t), [("tag1", "rev1")])
+
+    def test_merge_base_replacing(self):
+        """Non-conflicting merging with base where tags are replaced"""
+        self.t.set("tag1", "rev1")
+        self.t.set("tag2", "rev2")
+        base = Tags()
+        base.set("tag1", "rev1")
+        base.set("tag2", "rev2")
+        other = Tags()
+        other.set("tag1", "rev1")
+        other.set("tag2", "rev3")
+        self.t.merge(other, base)
+        self.assertEquals(list(self.t), [("tag1", "rev1"), ("tag2", "rev3")])
+
+    def test_merge_conflict(self):
+        """Conflicting merging"""
+        self.t.set("tag1", "rev1")
+        self.t.set("tag2", "rev2")
+        other = Tags()
+        other.set("tag2", "rev3")
+        self.t.merge(other)
+        self.assertEquals(list(self.t), [("tag1", "rev1"), ("tag2", "rev2")])
+
+    def test_merge_conflict_replace(self):
+        """Conflicting merging with replace=True"""
+        self.t.set("tag1", "rev1")
+        self.t.set("tag2", "rev2")
+        other = Tags()
+        other.set("tag2", "rev3")
+        self.t.merge(other, replace=True)
+        self.assertEquals(list(self.t), [("tag1", "rev1"), ("tag2", "rev3")])
+
+    def test_merge_base_conflict(self):
+        """Conflicting merging with base"""
+        self.t.set("tag1", "rev1")
+        self.t.set("tag2", "rev3")
+        base = Tags()
+        base.set("tag1", "rev1")
+        base.set("tag2", "rev2")
+        other = Tags()
+        other.set("tag1", "rev1")
+        other.set("tag2", "rev4")
+        self.t.merge(other, base)
+        self.assertEquals(list(self.t), [("tag1", "rev1"), ("tag2", "rev3")])
+
+    def test_merge_base_conflict_replace(self):
+        """Conflicting merging with base where replace=True"""
+        self.t.set("tag1", "rev1")
+        self.t.set("tag2", "rev3")
+        base = Tags()
+        base.set("tag1", "rev1")
+        base.set("tag2", "rev2")
+        other = Tags()
+        other.set("tag1", "rev1")
+        other.set("tag2", "rev4")
+        self.t.merge(other, base, replace=True)
+        self.assertEquals(list(self.t), [("tag1", "rev1"), ("tag2", "rev4")])
+
+    def test_merge_revisions(self):
+        """Merging only certain revisions"""
+        self.t.set("tag1", "rev1")
+        self.t.set("tag2", "rev2")
+        other = Tags()
+        other.set("tag3", "rev3")
+        other.set("tag4", "rev4")
+        self.t.merge(other, revision_ids="rev3")
+        self.assertEquals(list(self.t), [("tag1", "rev1"), ("tag2", "rev2"),
+                                         ("tag3", "rev3")])
+
+    def test_readonly(self):
+        """Can't write on readonly tags instance"""
+        self.t.set_readonly(True)
+        self.assertRaises(RuntimeError, self.t.set, "tag1", "rev1")
+
+    def test_copy(self):
+        """Set and get a couple of tags"""
+        self.t.set("tag1", "rev1")
+        self.t.set("tag2", "rev2")
+        t_copy = self.t.copy()
+        self.assertEquals(list(t_copy), list(self.t))
+
+    def test_equals(self):
+        """Equal tags are equal"""
+        self.t.set("tag1", "rev1")
+        self.t.set("tag2", "rev2")
+        other = Tags()
+        other.set("tag1", "rev1")
+        other.set("tag2", "rev2")
+        self.assertEquals(self.t, other)
+
+    def test_different(self):
+        """Different tags are different"""
+        self.t.set("tag1", "rev1")
+        self.t.set("tag2", "rev2")
+        other = Tags()
+        other.set("tag1", "rev1")
+        other.set("tag2", "rev3")
+        self.assertNotEquals(self.t, other)
+
+    def test_equal_operators(self):
+        """Test equal/not-equal operators"""
+        self.assertTrue(self.t == Tags())
+        self.assertFalse(self.t != Tags())
+
+    def test_different_unknown(self):
+        """Unknown objects are always different"""
+        self.assertNotEquals(self.t, None)
+
+    def test_contains(self):
+        """Verify if tag is contained"""
+        self.t.set("tag1", "rev1")
+        self.t.set("tag2", "rev2")
+        self.assertTrue("tag1" in self.t)
+        self.assertTrue("tag2" in self.t)
+        self.assertFalse("tag3" in self.t)
+
+    def test_true(self):
+        """Non-empty instance is true"""
+        self.t.set("tag1", "rev1")
+        self.t.set("tag2", "rev2")
+        self.assertTrue(self.t)
+
+    def test_false(self):
+        """Empty instance is false"""
+        self.assertFalse(self.t)

=== added file 'bzrlib/tags.py'
--- /dev/null
+++ bzrlib/tags.py
@@ -0,0 +1,148 @@
+#
+# Copyright (C) 2005 Canonical Ltd
+#
+# Written by Gustavo Niemeyer <gustavo at niemeyer.net>
+#
+# 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., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
+#
+from bzrlib.errors import InvalidTag, TagExists, NoSuchTag, NoSuchFile
+from bzrlib.trace import warning
+
+
+class Tags:
+
+    def __init__(self, text=None, readonly=False):
+        self._readonly = readonly
+        self._tags = {}
+        if text:
+            for line in text.splitlines():
+                tag, revision_id = line.split(':', 1)
+                self._tags[tag] = revision_id
+
+    def __str__(self):
+        lines = []
+        for item in self:
+            lines.append("%s:%s\n" % item)
+        return "".join(lines)
+
+    def __iter__(self):
+        tags = self._tags.keys()
+        tags.sort()
+        for tag in tags:
+            yield tag, self._tags[tag]
+
+    def __contains__(self, tag):
+        return tag in self._tags
+
+    def __nonzero__(self):
+        return bool(self._tags)
+
+    def __getitem__(self, tag):
+        return self.get(tag)
+
+    def __setitem__(self, tag, revision_id):
+        self.set(tag, revision_id)
+
+    def __delitem__(self, tag):
+        self.remove(tag)
+
+    def __eq__(self, other):
+        if not isinstance(other, Tags):
+            return False
+        return self._tags == other._tags
+
+    def __ne__(self, other):
+        return not self.__eq__(other)
+
+    def copy(self):
+        tags = Tags()
+        tags._tags.update(self._tags)
+        tags._readonly = self._readonly
+        return tags
+
+    def set_readonly(self, flag):
+        self._readonly = flag
+
+    def get(self, tag):
+        if tag in self._tags:
+            return self._tags[tag]
+        else:
+            raise NoSuchTag(tag)
+
+    def set(self, tag, revision_id, replace=False):
+        if self._readonly:
+            raise RuntimeError, "Trying to write in a readonly tag instance"
+        if ':' in tag or '@' in tag:
+            raise InvalidTag(tag)
+        if tag in self._tags and not replace:
+            raise TagExists(tag)
+        self._tags[tag] = revision_id
+
+    def remove(self, tag):
+        if self._readonly:
+            raise RuntimeError, "Trying to write in a readonly tag instance"
+        if ':' in tag or '@' in tag:
+            raise InvalidTag(tag)
+        if tag not in self._tags:
+            raise NoSuchTag(tag)
+        del self._tags[tag]
+
+    def merge(self, other, base=None, revision_ids=None,
+              replace=False, verbose=True):
+        """
+        :other:         Tags instance to copy tags from
+        :base:          Tags instance which is the base for three-way merge
+        :revision_ids:  Only consider tags having revision ids in that set
+        :replace:       If true current tags are replaced on conflict
+        :verbose:       Deliver warnings about conflicted tags
+        """
+        if self._readonly:
+            raise RuntimeError, "Trying to write in a readonly tag instance"
+
+        this_tags = self._tags
+        other_tags = other._tags
+        
+        if base is not None:
+            base_tags = base._tags
+        else:
+            base_tags = {}
+
+        tags = dict.fromkeys(other_tags)
+        tags.update(base_tags)
+        tags = tags.keys()
+        tags.sort()
+
+        for tag in tags:
+            this_revision_id = this_tags.get(tag)
+            base_revision_id = base_tags.get(tag)
+            other_revision_id = other_tags.get(tag)
+            if (base_revision_id == other_revision_id or
+                this_revision_id == other_revision_id):
+                continue
+            elif this_revision_id == base_revision_id:
+                if other_revision_id is None:
+                    del this_tags[tag]
+                elif (revision_ids is None or
+                      other_revision_id in revision_ids):
+                    this_tags[tag] = other_revision_id
+            elif replace:
+                if verbose:
+                    warning("replacing tag '%s'" % tag)
+                this_tags[tag] = other_revision_id
+            else:
+                if verbose:
+                    warning("won't change tag '%s' to point to '%s'"
+                            % (tag, other_revision_id))
+

=== modified file 'bzr' (properties changed)
=== modified file 'bzrlib/branch.py'
--- bzrlib/branch.py
+++ bzrlib/branch.py
@@ -32,7 +32,8 @@
 from bzrlib.errors import (BzrError, InvalidRevisionNumber, InvalidRevisionId,
                            NoSuchRevision, HistoryMissing, NotBranchError,
                            DivergedBranches, LockError, UnlistableStore,
-                           UnlistableBranch, NoSuchFile)
+                           UnlistableBranch, NoSuchFile, WeaveError,
+                           TagExists, NoSuchTag)
 from bzrlib.textui import show_status
 from bzrlib.revision import Revision
 from bzrlib.delta import compare_trees
@@ -43,6 +44,8 @@
 from bzrlib.store.text import TextStore
 from bzrlib.store.weave import WeaveStore
 from bzrlib.transport import Transport, get_transport
+from bzrlib.pendingactions import PendingActions
+from bzrlib.tags import Tags
 import bzrlib.xml5
 import bzrlib.ui
 
@@ -269,6 +272,9 @@
             self.weave_store = get_weave('weaves')
             self.revision_store = get_store('revision-store', compressed=False)
 
+        pa_relpath = self._rel_controlfilename("pending-actions")
+        self.pending_actions = PendingActions(self._transport, pa_relpath)
+
     def __str__(self):
         return '%s(%r)' % (self.__class__.__name__, self._transport.base)
 
@@ -446,7 +452,8 @@
             ('pending-merges', ''),
             ('inventory', empty_inv),
             ('inventory.weave', empty_weave),
-            ('ancestry.weave', empty_weave)
+            ('ancestry.weave', empty_weave),
+            ('tags.weave', empty_weave),
         ]
         cfn = self._rel_controlfilename
         self._transport.mkdir_multi([cfn(d) for d in dirs])
@@ -1266,9 +1273,55 @@
         """
         if revno < 1 or revno > self.revno():
             raise InvalidRevisionNumber(revno)
-        
-        
-        
+
+    def get_revision_tags_text(self, revision_id):
+        try:
+            w = self.control_weaves.get_weave('tags')
+            return w.get_text(revision_id)
+        except (NoSuchFile, WeaveError):
+            return ""
+
+    def get_revision_tags(self, revision_id):
+        try:
+            w = self.control_weaves.get_weave('tags')
+            return Tags(w.get_text(revision_id), readonly=True)
+        except (NoSuchFile, WeaveError):
+            return Tags(readonly=True)
+
+    def get_tags(self):
+        tags = self.get_revision_tags(self.last_revision())
+        tags.set_readonly(False)
+        for action in self.pending_actions:
+            kind = action[0]
+            if kind == 'set-tag':
+                tags[action[1]] = action[2]
+            elif kind == 'remove-tag':
+                del tags[action[1]]
+        tags.set_readonly(True)
+        return tags
+
+    def set_tag(self, tag, revision_id=None):
+        if revision_id is None:
+            revision_id = 'CURRENT'
+        tags = self.get_revision_tags(self.last_revision())
+        if tag in tags:
+            if ("remove-tag", tag) not in self.pending_actions:
+                raise TagExists(tag)
+            elif tags[tag] == revision_id:
+                self.pending_actions.remove("remove-tag", tag)
+                return
+        else:
+            if ("set-tag", tag, None) in self.pending_actions:
+                raise TagExists(tag)
+        self.pending_actions.add('set-tag', tag, revision_id)
+
+    def remove_tag(self, tag):
+        if self.pending_actions.remove('set-tag', tag, None):
+            return
+        tags = self.get_revision_tags(self.last_revision())
+        if tag not in tags:
+            raise NoSuchTag(tag)
+        self.pending_actions.add_unique('remove-tag', tag)
 
 
 class ScratchBranch(_Branch):

=== modified file 'bzrlib/builtins.py'
--- bzrlib/builtins.py
+++ bzrlib/builtins.py
@@ -1419,3 +1419,105 @@
                 print '\t', d.split('\n')[0]
 
 
+class cmd_set_tag(Command):
+    """Mark tags for inclusion and removal.
+
+    Tags are versioned, so they're not considered part of the branch
+    until the next commit.
+
+    examples:
+      bzr set-tag my-tag
+      bzr set-tag -r 42 my-tag
+
+    It is an error if the tag name exists.
+    """
+    takes_args = ['tag']
+    takes_options = ['revision']
+    
+    def run(self, tag, revision=None):
+        b = Branch.open_containing('.')
+        if revision is None:
+            revision_id = None
+        else:
+            if len(revision) > 1:
+                raise BzrCommandError('bzr tag --revision takes'
+                                      ' exactly one revision identifier')
+            revision_id = revision[0].in_history(b).rev_id
+        b.set_tag(tag, revision_id)
+
+class cmd_remove_tag(Command):
+    """Mark tags for inclusion and removal.
+
+    Tags are versioned, so they're not removed from the branch
+    until the next commit.
+
+    examples:
+      bzr remove-tag my-tag
+    """
+    takes_args = ['tag']
+    
+    def run(self, tag):
+        b = Branch.open_containing('.')
+        b.remove_tag(tag)
+
+class cmd_show_tags(Command):
+    """Show tags.
+    """
+
+    takes_options = ['revision']
+    
+    def run(self, revision=None):
+        b = Branch.open_containing('.')
+        if revision is None:
+            revision_id = b.last_revision()
+            show_changes = True
+        else:
+            if len(revision) > 1:
+                raise BzrCommandError('bzr tag --revision takes'
+                                      ' exactly one revision identifier')
+            revision_id = revision[0].in_history(b).rev_id
+            show_changes = False
+        revision_tags = b.get_revision_tags(revision_id)
+        current_tags = b.get_tags()
+        added_tags = []
+        removed_tags = []
+        changed_tags = []
+        def show_tag(tag, revision_id, changed_revision_id=None):
+            if changed_revision_id:
+                print "  ", tag+":", revision_id, "->"
+                print "  ", " "*(len(tag)+1), changed_revision_id
+            else:
+                print "  ", tag+":", revision_id
+
+        if current_tags:
+            print
+            print "Pristine tags:"
+            for tag, revision_id in revision_tags:
+                show_tag(tag, revision_id)
+                if show_changes:
+                    if tag not in current_tags:
+                        removed_tags.append((tag, revision_id))
+                    elif current_tags.get(tag) != revision_id:
+                        changed_tags.append(tag)
+        if show_changes:
+            for tag, revision_id in current_tags:
+                if tag not in revision_tags:
+                    added_tags.append((tag, revision_id))
+            if added_tags:
+                print
+                print "Added tags:"
+                for tag, revision_id in added_tags:
+                    show_tag(tag, revision_id)
+            if removed_tags:
+                print
+                print "Removed tags:"
+                for tag, revision_id in removed_tags:
+                    show_tag(tag, revision_id)
+            if changed_tags:
+                print
+                print "Changed tags:"
+                for tag in changed_tags:
+                    show_tag(tag, revision_tags.get(tag),
+                             current_tags.get(tag))
+        print
+

=== modified file 'bzrlib/clone.py'
--- bzrlib/clone.py
+++ bzrlib/clone.py
@@ -52,6 +52,7 @@
 from bzrlib.branch import Branch
 from bzrlib.trace import mutter, note
 from bzrlib.store import copy_all
+from bzrlib.errors import NoSuchFile
 
 def copy_branch(branch_from, to_location, revision=None, basis_branch=None):
     """Copy branch_from into the existing directory to_location.
@@ -117,7 +118,10 @@
 def _copy_control_weaves(branch_from, branch_to):
     to_control = branch_to.control_weaves
     from_control = branch_from.control_weaves
-    to_control.copy_multi(from_control, ['inventory'])
+    try:
+        to_control.copy_multi(from_control, ['inventory', 'tags'])
+    except NoSuchFile:
+        pass
 
     
 def copy_branch_slower(branch_from, to_location, revision=None, basis_branch=None):

=== modified file 'bzrlib/commit.py'
--- bzrlib/commit.py
+++ bzrlib/commit.py
@@ -216,6 +216,9 @@
             self.basis_tree = self.branch.basis_tree()
             self.basis_inv = self.basis_tree.inventory
 
+            self.basis_tags = self.branch.get_revision_tags(self.branch.last_revision())
+            self.new_tags = self.branch.get_tags()
+
             self._gather_parents()
             if len(self.parents) > 1 and self.specific_files:
                 raise NotImplementedError('selected-file commit of merges is not supported yet')
@@ -228,17 +231,20 @@
 
             if not (self.allow_pointless
                     or len(self.parents) > 1
-                    or self.new_inv != self.basis_inv):
+                    or self.new_inv != self.basis_inv
+                    or self.new_tags != self.basis_tags):
                 raise PointlessCommit()
 
             if len(list(self.work_tree.iter_conflicts()))>0:
                 raise ConflictsInTree
 
             self._record_inventory()
+            self._record_tags()
             self._make_revision()
             self.reporter.completed(self.branch.revno()+1, self.rev_id)
             self.branch.append_revision(self.rev_id)
             self.branch.set_pending_merges([])
+            self.branch.pending_actions.remove_all()
         finally:
             self.branch.unlock()
 
@@ -249,6 +255,17 @@
         s = self.branch.control_weaves
         s.add_text('inventory', self.rev_id,
                    split_lines(inv_text), self.present_parents)
+
+    def _record_tags(self):
+        """Store the tags for the new revision."""
+        tags = self.new_tags
+        tags.set_readonly(False)
+        for tag, revision_id in tags:
+            if revision_id == 'CURRENT':
+                tags.set(tag, self.rev_id, replace=True)
+        s = self.branch.control_weaves
+        s.add_text('tags', self.rev_id,
+                   split_lines(str(tags)), self.present_parents)
 
     def _escape_commit_message(self):
         """Replace xml-incompatible control characters."""

=== modified file 'bzrlib/errors.py'
--- bzrlib/errors.py
+++ bzrlib/errors.py
@@ -247,3 +247,24 @@
 class ConflictsInTree(BzrError):
     def __init__(self):
         BzrError.__init__(self, "Working tree has conflicts.")
+
+class InvalidTag(BzrError):
+    def __init__(self, tag):
+        self.tag = tag
+        BzrError.__init__(self, "Invalid tag: %s" % str(tag))
+
+class ReadonlyTags(BzrError):
+    def __init__(self, tag):
+        self.tag = tag
+        BzrError.__init__(self, "Tags instance is readonly")
+
+class TagExists(BzrError):
+    def __init__(self, tag):
+        self.tag = tag
+        BzrError.__init__(self, "Tag already exists: %s" % str(tag))
+
+class NoSuchTag(BzrError):
+    def __init__(self, tag):
+        self.tag = tag
+        BzrError.__init__(self, "Tag not found: %s" % str(tag))
+

=== modified file 'bzrlib/fetch.py'
--- bzrlib/fetch.py
+++ bzrlib/fetch.py
@@ -177,6 +177,7 @@
         mutter('copying revision {%s}', rev_id)
         rev_xml = self.from_branch.get_revision_xml(rev_id)
         inv_xml = self.from_branch.get_inventory_xml(rev_id)
+        tags_text = self.from_branch.get_revision_tags_text(rev_id)
         rev = serializer_v5.read_revision_from_string(rev_xml)
         inv = serializer_v5.read_inventory_from_string(inv_xml)
         assert rev.revision_id == rev_id
@@ -190,6 +191,7 @@
             if not self.to_branch.has_revision(parent):
                 parents.pop(parents.index(parent))
         self._copy_inventory(rev_id, inv_xml, parents)
+        self._copy_tags(rev_id, tags_text, parents)
         self.to_branch.revision_store.add(StringIO(rev_xml), rev_id)
         mutter('copied revision %s', rev_id)
 
@@ -197,6 +199,10 @@
     def _copy_inventory(self, rev_id, inv_xml, parent_ids):
         self.to_control.add_text('inventory', rev_id,
                                 split_lines(inv_xml), parent_ids)
+
+    def _copy_tags(self, rev_id, tags_text, parent_ids):
+        self.to_control.add_text('tags', rev_id,
+                                 split_lines(tags_text), parent_ids)
 
     def _copy_new_texts(self, rev_id, inv):
         """Copy any new texts occuring in this revision."""

=== modified file 'bzrlib/merge.py'
--- bzrlib/merge.py
+++ bzrlib/merge.py
@@ -338,6 +338,7 @@
                 raise BzrCommandError("Working tree has uncommitted changes.")
         other_branch, other_tree = get_tree(other_revision, tempdir, "other",
                                             this_branch)
+
         if other_revision[1] == -1:
             other_rev_id = other_branch.last_revision()
             if other_rev_id is None:
@@ -357,6 +358,7 @@
                                               this_branch)
             except NoCommonAncestor:
                 raise UnrelatedBranches()
+            base_branch = this_branch
             base_tree = get_revid_tree(this_branch, base_rev_id, tempdir, 
                                        "base", None)
             base_is_ancestor = True
@@ -393,9 +395,35 @@
         if base_is_ancestor and other_rev_id is not None\
             and other_rev_id not in this_branch.revision_history():
             this_branch.add_pending_merge(other_rev_id)
+
+        other_tags = other_branch.get_revision_tags(other_basis)
+        if base_rev_id:
+            base_tags = base_branch.get_revision_tags(base_rev_id)
+        else:
+            base_tags = None
+        merge_tags(this_branch, other_tags, base_tags)
+
     finally:
         shutil.rmtree(tempdir)
 
+def merge_tags(branch, other_tags, base_tags):
+    current_tags = branch.get_tags()
+    merged_tags = current_tags.copy()
+    merged_tags.set_readonly(False)
+    merged_tags.merge(other_tags, base_tags)
+    tags = dict(current_tags)
+    tags.update(dict(merged_tags))
+    for tag in tags:
+        if tag not in current_tags:
+            branch.set_tag(tag, merged_tags[tag])
+        elif tag not in merged_tags:
+            branch.remove_tag(tag)
+        elif current_tags[tag] != merged_tags[tag]:
+            branch.remove_tag(tag)
+            try:
+                branch.set_tag(tag, merged_tags[tag])
+            except:
+                import pdb; pdb.set_trace()
 
 def set_interesting(inventory_a, inventory_b, interesting_ids):
     """Mark files whose ids are in interesting_ids as interesting

=== modified file 'bzrlib/revisionspec.py'
--- bzrlib/revisionspec.py
+++ bzrlib/revisionspec.py
@@ -233,7 +233,12 @@
     prefix = 'tag:'
 
     def _match_on(self, branch, revs):
-        raise BzrError('tag: namespace registered, but not implemented.')
+        revision_id = branch.get_tags()[self.spec]
+        try:
+            return RevisionInfo(branch, revs.index(revision_id) + 1,
+                                revision_id)
+        except ValueError:
+            return RevisionInfo(branch, None)
 
 SPEC_TYPES.append(RevisionSpec_tag)
 

=== modified file 'bzrlib/selftest/__init__.py'
--- bzrlib/selftest/__init__.py
+++ bzrlib/selftest/__init__.py
@@ -488,6 +488,8 @@
                    'bzrlib.selftest.testworkingtree',
                    'bzrlib.selftest.test_upgrade',
                    'bzrlib.selftest.test_conflicts',
+                   'bzrlib.selftest.testtags',
+                   'bzrlib.selftest.testpendingactions',
                    ]
 
     for m in (bzrlib.store, bzrlib.inventory, bzrlib.branch,

=== modified file 'bzrlib/selftest/testbranch.py'
--- bzrlib/selftest/testbranch.py
+++ bzrlib/selftest/testbranch.py
@@ -158,3 +158,65 @@
 #         Added 0 revisions.
 #         >>> br1.text_store.total_size() == br2.text_store.total_size()
 #         True
+
+
+
+    def test_tags(self):
+        """Basic tags functionality"""
+        b = Branch.initialize('.')
+        commit(b, "revision 1", allow_pointless=True)
+        rev_id1 = b.last_revision()
+        self.assertEquals(list(b.get_revision_tags(rev_id1)), [])
+        self.assertEquals(list(b.get_tags()), [])
+        b.set_tag("tag1", "rev1")
+        b.set_tag("tag2", "rev2")
+        b.set_tag("tag3", "rev3")
+        b.remove_tag("tag2")
+        self.assertEquals(list(b.get_revision_tags(rev_id1)), [])
+        self.assertEquals(list(b.get_tags()),
+                          [("tag1", "rev1"),
+                           ("tag3", "rev3")])
+        commit(b, "revision 2")
+        rev_id2 = b.last_revision()
+        b.set_tag("tag2", "rev2")
+        self.assertEquals(list(b.get_revision_tags(rev_id1)), [])
+        self.assertEquals(list(b.get_revision_tags(rev_id2)),
+                          [("tag1", "rev1"),
+                           ("tag3", "rev3")])
+        self.assertEquals(list(b.get_tags()),
+                          [("tag1", "rev1"),
+                           ("tag2", "rev2"),
+                           ("tag3", "rev3")])
+        commit(b, "revision 3")
+        rev_id3 = b.last_revision()
+        self.assertEquals(list(b.get_revision_tags(rev_id1)), [])
+        self.assertEquals(list(b.get_revision_tags(rev_id2)),
+                          [("tag1", "rev1"),
+                           ("tag3", "rev3")])
+        self.assertEquals(list(b.get_revision_tags(rev_id3)),
+                          [("tag1", "rev1"),
+                           ("tag2", "rev2"),
+                           ("tag3", "rev3")])
+        self.assertEquals(list(b.get_tags()),
+                          [("tag1", "rev1"),
+                           ("tag2", "rev2"),
+                           ("tag3", "rev3")])
+        
+        from bzrlib.errors import TagExists, NoSuchTag
+        self.assertRaises(TagExists, b.set_tag, "tag1", "rev1")
+        self.assertRaises(TagExists, b.set_tag, "tag1", "revN")
+        self.assertRaises(TagExists, b.set_tag, "tag2", "rev2")
+        self.assertRaises(TagExists, b.set_tag, "tag2", "revN")
+        self.assertRaises(TagExists, b.set_tag, "tag3", "rev3")
+        self.assertRaises(TagExists, b.set_tag, "tag3", "revN")
+        self.assertRaises(NoSuchTag, b.remove_tag, "tag4")
+
+        b.set_tag("tag4", "rev4")
+        self.assertRaises(TagExists, b.set_tag, "tag4", "rev4")
+        self.assertRaises(TagExists, b.set_tag, "tag4", "revN")
+
+        self.assertRaises(RuntimeError, b.get_revision_tags(rev_id1).set,
+                          "tag5", "rev5")
+        self.assertRaises(RuntimeError, b.get_tags().set,
+                          "tag5", "rev5")
+

=== modified file 'bzrlib/selftest/testmerge.py'
--- bzrlib/selftest/testmerge.py
+++ bzrlib/selftest/testmerge.py
@@ -4,6 +4,7 @@
 from bzrlib.commit import commit
 from bzrlib.selftest import TestCaseInTempDir
 from bzrlib.merge import merge
+from bzrlib.clone import copy_branch
 from bzrlib.errors import UnrelatedBranches, NoCommits
 from bzrlib.revision import common_ancestor
 from bzrlib.fetch import fetch
@@ -50,3 +51,172 @@
         commit(br1, "blah")
         last = br1.last_revision()
         self.assertEquals(common_ancestor(last, last, br1), last)
+
+    def test_merge_tags_adding(self):
+        """Merge tags there were added"""
+        os.mkdir("branch1")
+        br1 = Branch.initialize("branch1")
+        br1.set_tag("tag1", "rev1")
+        br1.set_tag("tag2", "rev2")
+        commit(br1, "branch 1 revision 1")
+        br1.set_tag("tag5", "rev5")
+        br1.set_tag("tag6", "rev6")
+
+        os.mkdir("branch2")
+        br2 = Branch.initialize("branch2")
+        br2.set_tag("tag3", "rev3")
+        br2.set_tag("tag4", "rev4")
+        commit(br2, "branch 2 revision 1")
+
+        merge(["branch2", -1], ["branch2", 0], this_dir="branch1")
+
+        self.assertEquals(list(br1.get_tags()),
+                          [("tag1", "rev1"), ("tag2", "rev2"),
+                           ("tag3", "rev3"), ("tag4", "rev4"),
+                           ("tag5", "rev5"), ("tag6", "rev6")])
+
+    def test_merge_tags_matching(self):
+        """Merge tags matching precisely"""
+        os.mkdir("branch1")
+        br1 = Branch.initialize("branch1")
+        br1.set_tag("tag1", "rev1")
+        br1.set_tag("tag2", "rev2")
+        commit(br1, "branch 1 revision 1")
+        br1.set_tag("tag3", "rev3")
+        br1.set_tag("tag4", "rev4")
+
+        os.mkdir("branch2")
+        br2 = Branch.initialize("branch2")
+        br2.set_tag("tag1", "rev1")
+        br2.set_tag("tag2", "rev2")
+        br2.set_tag("tag3", "rev3")
+        br2.set_tag("tag4", "rev4")
+        commit(br2, "branch 2 revision 1")
+
+        merge(['branch2', -1], ["branch2", 0], this_dir="branch1")
+
+        self.assertEquals(list(br1.get_tags()),
+                          [("tag1", "rev1"), ("tag2", "rev2"),
+                           ("tag3", "rev3"), ("tag4", "rev4")])
+
+    def test_merge_tags_adding_with_base(self):
+        """Merge tags there were added, with a common base"""
+        os.mkdir("branch1")
+        br1 = Branch.initialize("branch1")
+        br1.set_tag("tag1", "rev1")
+        br1.set_tag("tag2", "rev2")
+        commit(br1, "branch 1 revision 1")
+        br1.set_tag("tag5", "rev5")
+        br1.set_tag("tag6", "rev6")
+
+        copy_branch(br1, "branch2")
+        br2 = Branch.open("branch2")
+        br2.set_tag("tag3", "rev3")
+        br2.set_tag("tag4", "rev4")
+        commit(br2, "branch 2 revision 1")
+
+        merge(['branch2', -1], [None, None], this_dir="branch1")
+
+        self.assertEquals(list(br1.get_tags()),
+                          [("tag1", "rev1"), ("tag2", "rev2"),
+                           ("tag3", "rev3"), ("tag4", "rev4"),
+                           ("tag5", "rev5"), ("tag6", "rev6")])
+
+    def test_merge_tags_matching_with_base(self):
+        """Merge tags matching precisely, with common base"""
+        os.mkdir("branch1")
+        br1 = Branch.initialize("branch1")
+        br1.set_tag("tag1", "rev1")
+        br1.set_tag("tag2", "rev2")
+        commit(br1, "branch 1 revision 1")
+        br1.set_tag("tag3", "rev3")
+        br1.set_tag("tag4", "rev4")
+
+        copy_branch(br1, "branch2")
+        br2 = Branch.open("branch2")
+        br2.set_tag("tag3", "rev3")
+        br2.set_tag("tag4", "rev4")
+        commit(br2, "branch 2 revision 1")
+
+        merge(['branch2', -1], [None, None], this_dir="branch1")
+
+        self.assertEquals(list(br1.get_tags()),
+                          [("tag1", "rev1"), ("tag2", "rev2"),
+                           ("tag3", "rev3"), ("tag4", "rev4")])
+
+    def test_merge_tags_matching_with_base(self):
+        """Merge tags matching precisely, with common base"""
+        os.mkdir("branch1")
+        br1 = Branch.initialize("branch1")
+        br1.set_tag("tag1", "rev1")
+        br1.set_tag("tag2", "rev2")
+        commit(br1, "branch 1 revision 1")
+
+        copy_branch(br1, "branch2")
+        br2 = Branch.open("branch2")
+        br2.set_tag("tag3", "rev3")
+        br2.set_tag("tag4", "rev4")
+        commit(br2, "branch 2 revision 1")
+
+        br1.set_tag("tag3", "rev3")
+        br1.set_tag("tag4", "rev4")
+
+        merge(['branch2', -1], [None, None], this_dir="branch1")
+
+        self.assertEquals(list(br1.get_tags()),
+                          [("tag1", "rev1"), ("tag2", "rev2"),
+                           ("tag3", "rev3"), ("tag4", "rev4")])
+
+    def test_merge_tags_changed(self):
+        """Merge tags when tags were changed, including conflicts"""
+        os.mkdir("branch1")
+        br1 = Branch.initialize("branch1")
+        br1.set_tag("tag1", "rev1")
+        br1.set_tag("tag2", "rev2")
+        br1.set_tag("tag3", "rev3")
+        commit(br1, "branch 1 revision 1")
+
+        copy_branch(br1, "branch2")
+        br2 = Branch.open("branch2")
+        # Removed
+        br2.remove_tag("tag2")
+        # Replaced
+        br2.remove_tag("tag3")
+        br2.set_tag("tag3", "revN")
+        # Conflicting and committed
+        br2.set_tag("tag4", "revN")
+        # Conflicting and uncommitted
+        br2.set_tag("tag6", "revN")
+        # Added
+        br2.set_tag("tag8", "rev8")
+        commit(br2, "branch 2 revision 1")
+
+        br1.set_tag("tag4", "rev4")
+        br1.set_tag("tag5", "rev5")
+        commit(br1, "branch 1 revision 2")
+
+        br1.set_tag("tag6", "rev6")
+        br1.set_tag("tag7", "rev7")
+
+        merge(['branch2', -1], [None, None], this_dir="branch1")
+
+        self.assertEquals(list(br1.get_tags()),
+                          [
+                           # From branch1, unchanged
+                             ("tag1", "rev1"),
+                           # From branch1, removed on branch2
+                           # ("tag2", "rev2"),
+                           # From branch1, replaced by branch2
+                             ("tag3", "revN"),
+                           # From branch1, committed/conflicted/preserved
+                             ("tag4", "rev4"),
+                           # From branch1, committed
+                             ("tag5", "rev5"),
+                           # From branch1, uncommitted/conflicted/preserved
+                             ("tag6", "rev6"),
+                           # From branch1, uncommitted
+                             ("tag7", "rev7"),
+                           # From branch2, added
+                             ("tag8", "rev8")
+                          ])
+

=== modified file 'bzrlib/status.py'
--- bzrlib/status.py
+++ bzrlib/status.py
@@ -89,6 +89,15 @@
                 print >>to_file, 'pending merges:'
                 for merge in branch.pending_merges():
                     print >> to_file, ' ', merge
+            if show_pending and branch.pending_actions:
+                print >>to_file, 'pending actions:'
+                for action in branch.pending_actions:
+                    kind = action[0]
+                    if kind == 'set-tag':
+                        print >>to_file, "  set tag %s to %s" % action[1:]
+                    elif kind == 'remove-tag':
+                        print >>to_file, "  remove tag %s" % action[1]
+
     finally:
         branch.unlock()
         



More information about the bazaar mailing list