journal: add share extension support
authorMartijn Pieters <mjpieters@fb.com>
Mon, 11 Jul 2016 14:45:41 +0100
changeset 29503 0103b673d6ca
parent 29502 8361131b4768
child 29504 7503d8874617
journal: add share extension support Rather than put everything into one journal file, split entries up in *shared* and *local* entries. Working copy changes are local to a specific working copy, so should remain local only. Other entries are shared with the source if so configured when the share was created. When unsharing, any shared journale entries are copied across.
hgext/journal.py
tests/test-journal-share.t
--- a/hgext/journal.py	Mon Jul 11 13:39:24 2016 +0100
+++ b/hgext/journal.py	Mon Jul 11 14:45:41 2016 +0100
@@ -14,6 +14,7 @@
 from __future__ import absolute_import
 
 import collections
+import errno
 import os
 import weakref
 
@@ -27,12 +28,15 @@
     dispatch,
     error,
     extensions,
+    hg,
     localrepo,
     lock,
     node,
     util,
 )
 
+from . import share
+
 cmdtable = {}
 command = cmdutil.command(cmdtable)
 
@@ -48,6 +52,11 @@
 # namespaces
 bookmarktype = 'bookmark'
 wdirparenttype = 'wdirparent'
+# In a shared repository, what shared feature name is used
+# to indicate this namespace is shared with the source?
+sharednamespaces = {
+    bookmarktype: hg.sharedbookmarks,
+}
 
 # Journal recording, register hooks and storage object
 def extsetup(ui):
@@ -57,6 +66,8 @@
         dirstate.dirstate, '_writedirstate', recorddirstateparents)
     extensions.wrapfunction(
         localrepo.localrepository.dirstate, 'func', wrapdirstate)
+    extensions.wrapfunction(hg, 'postshare', wrappostshare)
+    extensions.wrapfunction(hg, 'copystore', unsharejournal)
 
 def reposetup(ui, repo):
     if repo.local():
@@ -114,6 +125,74 @@
                 repo.journal.record(bookmarktype, mark, oldvalue, value)
     return orig(store, fp)
 
+# shared repository support
+def _readsharedfeatures(repo):
+    """A set of shared features for this repository"""
+    try:
+        return set(repo.vfs.read('shared').splitlines())
+    except IOError as inst:
+        if inst.errno != errno.ENOENT:
+            raise
+        return set()
+
+def _mergeentriesiter(*iterables, **kwargs):
+    """Given a set of sorted iterables, yield the next entry in merged order
+
+    Note that by default entries go from most recent to oldest.
+    """
+    order = kwargs.pop('order', max)
+    iterables = [iter(it) for it in iterables]
+    # this tracks still active iterables; iterables are deleted as they are
+    # exhausted, which is why this is a dictionary and why each entry also
+    # stores the key. Entries are mutable so we can store the next value each
+    # time.
+    iterable_map = {}
+    for key, it in enumerate(iterables):
+        try:
+            iterable_map[key] = [next(it), key, it]
+        except StopIteration:
+            # empty entry, can be ignored
+            pass
+
+    while iterable_map:
+        value, key, it = order(iterable_map.itervalues())
+        yield value
+        try:
+            iterable_map[key][0] = next(it)
+        except StopIteration:
+            # this iterable is empty, remove it from consideration
+            del iterable_map[key]
+
+def wrappostshare(orig, sourcerepo, destrepo, **kwargs):
+    """Mark this shared working copy as sharing journal information"""
+    orig(sourcerepo, destrepo, **kwargs)
+    with destrepo.vfs('shared', 'a') as fp:
+        fp.write('journal\n')
+
+def unsharejournal(orig, ui, repo, repopath):
+    """Copy shared journal entries into this repo when unsharing"""
+    if (repo.path == repopath and repo.shared() and
+            util.safehasattr(repo, 'journal')):
+        sharedrepo = share._getsrcrepo(repo)
+        sharedfeatures = _readsharedfeatures(repo)
+        if sharedrepo and sharedfeatures > set(['journal']):
+            # there is a shared repository and there are shared journal entries
+            # to copy. move shared date over from source to destination but
+            # move the local file first
+            if repo.vfs.exists('journal'):
+                journalpath = repo.join('journal')
+                util.rename(journalpath, journalpath + '.bak')
+            storage = repo.journal
+            local = storage._open(
+                repo.vfs, filename='journal.bak', _newestfirst=False)
+            shared = (
+                e for e in storage._open(sharedrepo.vfs, _newestfirst=False)
+                if sharednamespaces.get(e.namespace) in sharedfeatures)
+            for entry in _mergeentriesiter(local, shared, order=min):
+                storage._write(repo.vfs, entry)
+
+    return orig(ui, repo, repopath)
+
 class journalentry(collections.namedtuple(
         'journalentry',
         'timestamp user command namespace name oldhashes newhashes')):
@@ -157,6 +236,10 @@
 class journalstorage(object):
     """Storage for journal entries
 
+    Entries are divided over two files; one with entries that pertain to the
+    local working copy *only*, and one with entries that are shared across
+    multiple working copies when shared using the share extension.
+
     Entries are stored with NUL bytes as separators. See the journalentry
     class for the per-entry structure.
 
@@ -175,6 +258,15 @@
         self.ui = repo.ui
         self.vfs = repo.vfs
 
+        # is this working copy using a shared storage?
+        self.sharedfeatures = self.sharedvfs = None
+        if repo.shared():
+            features = _readsharedfeatures(repo)
+            sharedrepo = share._getsrcrepo(repo)
+            if sharedrepo is not None and 'journal' in features:
+                self.sharedvfs = sharedrepo.vfs
+                self.sharedfeatures = features
+
     # track the current command for recording in journal entries
     @property
     def command(self):
@@ -192,19 +284,19 @@
         # with a non-local repo (cloning for example).
         cls._currentcommand = fullargs
 
-    def jlock(self):
+    def jlock(self, vfs):
         """Create a lock for the journal file"""
         if self._lockref and self._lockref():
             raise error.Abort(_('journal lock does not support nesting'))
-        desc = _('journal of %s') % self.vfs.base
+        desc = _('journal of %s') % vfs.base
         try:
-            l = lock.lock(self.vfs, 'journal.lock', 0, desc=desc)
+            l = lock.lock(vfs, 'journal.lock', 0, desc=desc)
         except error.LockHeld as inst:
             self.ui.warn(
                 _("waiting for lock on %s held by %r\n") % (desc, inst.locker))
             # default to 600 seconds timeout
             l = lock.lock(
-                self.vfs, 'journal.lock',
+                vfs, 'journal.lock',
                 int(self.ui.config("ui", "timeout", "600")), desc=desc)
             self.ui.warn(_("got lock after %s seconds\n") % l.delay)
         self._lockref = weakref.ref(l)
@@ -231,10 +323,20 @@
             util.makedate(), self.user, self.command, namespace, name,
             oldhashes, newhashes)
 
-        with self.jlock():
+        vfs = self.vfs
+        if self.sharedvfs is not None:
+            # write to the shared repository if this feature is being
+            # shared between working copies.
+            if sharednamespaces.get(namespace) in self.sharedfeatures:
+                vfs = self.sharedvfs
+
+        self._write(vfs, entry)
+
+    def _write(self, vfs, entry):
+        with self.jlock(vfs):
             version = None
             # open file in amend mode to ensure it is created if missing
-            with self.vfs('journal', mode='a+b', atomictemp=True) as f:
+            with vfs('journal', mode='a+b', atomictemp=True) as f:
                 f.seek(0, os.SEEK_SET)
                 # Read just enough bytes to get a version number (up to 2
                 # digits plus separator)
@@ -273,10 +375,23 @@
         Yields journalentry instances for each contained journal record.
 
         """
-        if not self.vfs.exists('journal'):
+        local = self._open(self.vfs)
+
+        if self.sharedvfs is None:
+            return local
+
+        # iterate over both local and shared entries, but only those
+        # shared entries that are among the currently shared features
+        shared = (
+            e for e in self._open(self.sharedvfs)
+            if sharednamespaces.get(e.namespace) in self.sharedfeatures)
+        return _mergeentriesiter(local, shared)
+
+    def _open(self, vfs, filename='journal', _newestfirst=True):
+        if not vfs.exists(filename):
             return
 
-        with self.vfs('journal') as f:
+        with vfs(filename) as f:
             raw = f.read()
 
         lines = raw.split('\0')
@@ -285,8 +400,12 @@
             version = version or _('not available')
             raise error.Abort(_("unknown journal file version '%s'") % version)
 
-        # Skip the first line, it's a version number. Reverse the rest.
-        lines = reversed(lines[1:])
+        # Skip the first line, it's a version number. Normally we iterate over
+        # these in reverse order to list newest first; only when copying across
+        # a shared storage do we forgo reversing.
+        lines = lines[1:]
+        if _newestfirst:
+            lines = reversed(lines)
         for line in lines:
             if not line:
                 continue
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tests/test-journal-share.t	Mon Jul 11 14:45:41 2016 +0100
@@ -0,0 +1,153 @@
+Journal extension test: tests the share extension support
+
+  $ cat >> testmocks.py << EOF
+  > # mock out util.getuser() and util.makedate() to supply testable values
+  > import os
+  > from mercurial import util
+  > def mockgetuser():
+  >     return 'foobar'
+  > 
+  > def mockmakedate():
+  >     filename = os.path.join(os.environ['TESTTMP'], 'testtime')
+  >     try:
+  >         with open(filename, 'rb') as timef:
+  >             time = float(timef.read()) + 1
+  >     except IOError:
+  >         time = 0.0
+  >     with open(filename, 'wb') as timef:
+  >         timef.write(str(time))
+  >     return (time, 0)
+  > 
+  > util.getuser = mockgetuser
+  > util.makedate = mockmakedate
+  > EOF
+
+  $ cat >> $HGRCPATH << EOF
+  > [extensions]
+  > journal=
+  > share=
+  > testmocks=`pwd`/testmocks.py
+  > [remotenames]
+  > rename.default=remote
+  > EOF
+
+  $ hg init repo
+  $ cd repo
+  $ hg bookmark bm
+  $ touch file0
+  $ hg commit -Am 'file0 added'
+  adding file0
+  $ hg journal --all
+  previous locations of the working copy and bookmarks:
+  5640b525682e  .         commit -Am 'file0 added'
+  5640b525682e  bm        commit -Am 'file0 added'
+
+A shared working copy initially receives the same bookmarks and working copy
+
+  $ cd ..
+  $ hg share repo shared1
+  updating working directory
+  1 files updated, 0 files merged, 0 files removed, 0 files unresolved
+  $ cd shared1
+  $ hg journal --all
+  previous locations of the working copy and bookmarks:
+  5640b525682e  .         share repo shared1
+
+unless you explicitly share bookmarks
+
+  $ cd ..
+  $ hg share --bookmarks repo shared2
+  updating working directory
+  1 files updated, 0 files merged, 0 files removed, 0 files unresolved
+  $ cd shared2
+  $ hg journal --all
+  previous locations of the working copy and bookmarks:
+  5640b525682e  .         share --bookmarks repo shared2
+  5640b525682e  bm        commit -Am 'file0 added'
+
+Moving the bookmark in the original repository is only shown in the repository
+that shares bookmarks
+
+  $ cd ../repo
+  $ touch file1
+  $ hg commit -Am "file1 added"
+  adding file1
+  $ cd ../shared1
+  $ hg journal --all
+  previous locations of the working copy and bookmarks:
+  5640b525682e  .         share repo shared1
+  $ cd ../shared2
+  $ hg journal --all
+  previous locations of the working copy and bookmarks:
+  6432d239ac5d  bm        commit -Am 'file1 added'
+  5640b525682e  .         share --bookmarks repo shared2
+  5640b525682e  bm        commit -Am 'file0 added'
+
+But working copy changes are always 'local'
+
+  $ cd ../repo
+  $ hg up 0
+  0 files updated, 0 files merged, 1 files removed, 0 files unresolved
+  (leaving bookmark bm)
+  $ hg journal --all
+  previous locations of the working copy and bookmarks:
+  5640b525682e  .         up 0
+  6432d239ac5d  .         commit -Am 'file1 added'
+  6432d239ac5d  bm        commit -Am 'file1 added'
+  5640b525682e  .         commit -Am 'file0 added'
+  5640b525682e  bm        commit -Am 'file0 added'
+  $ cd ../shared2
+  $ hg journal --all
+  previous locations of the working copy and bookmarks:
+  6432d239ac5d  bm        commit -Am 'file1 added'
+  5640b525682e  .         share --bookmarks repo shared2
+  5640b525682e  bm        commit -Am 'file0 added'
+  $ hg up tip
+  1 files updated, 0 files merged, 0 files removed, 0 files unresolved
+  $ hg up 0
+  0 files updated, 0 files merged, 1 files removed, 0 files unresolved
+  $ hg journal
+  previous locations of '.':
+  5640b525682e  up 0
+  6432d239ac5d  up tip
+  5640b525682e  share --bookmarks repo shared2
+
+Unsharing works as expected; the journal remains consistent
+
+  $ cd ../shared1
+  $ hg unshare
+  $ hg journal --all
+  previous locations of the working copy and bookmarks:
+  5640b525682e  .         share repo shared1
+  $ cd ../shared2
+  $ hg unshare
+  $ hg journal --all
+  previous locations of the working copy and bookmarks:
+  5640b525682e  .         up 0
+  6432d239ac5d  .         up tip
+  6432d239ac5d  bm        commit -Am 'file1 added'
+  5640b525682e  .         share --bookmarks repo shared2
+  5640b525682e  bm        commit -Am 'file0 added'
+
+New journal entries in the source repo no longer show up in the other working copies
+
+  $ cd ../repo
+  $ hg bookmark newbm -r tip
+  $ hg journal newbm
+  previous locations of 'newbm':
+  6432d239ac5d  bookmark newbm -r tip
+  $ cd ../shared2
+  $ hg journal newbm
+  previous locations of 'newbm':
+  no recorded locations
+
+This applies for both directions
+
+  $ hg bookmark shared2bm -r tip
+  $ hg journal shared2bm
+  previous locations of 'shared2bm':
+  6432d239ac5d  bookmark shared2bm -r tip
+  $ cd ../repo
+  $ hg journal shared2bm
+  previous locations of 'shared2bm':
+  no recorded locations