Patchwork [2,of,2] journal: add share extension support

login
register
mail settings
Submitter Martijn Pieters
Date June 30, 2016, 12:41 p.m.
Message ID <cdba1452e049f678c3c8.1467290486@mjpieters-mbp.dhcp.thefacebook.com>
Download mbox | patch
Permalink /patch/15690/
State Changes Requested
Delegated to: Yuya Nishihara
Headers show

Comments

Martijn Pieters - June 30, 2016, 12:41 p.m.
# HG changeset patch
# User Martijn Pieters <mjpieters@fb.com>
# Date 1467290363 -3600
#      Thu Jun 30 13:39:23 2016 +0100
# Node ID cdba1452e049f678c3c8ee6b55400e175ddbd5be
# Parent  09c7b17ab01c202460543f02d9d71ac643f85a20
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.

Patch

diff --git a/hgext/journal.py b/hgext/journal.py
--- a/hgext/journal.py
+++ b/hgext/journal.py
@@ -14,6 +14,7 @@ 
 from __future__ import absolute_import
 
 import collections
+import errno
 import os
 
 from mercurial.i18n import _
@@ -26,11 +27,14 @@ 
     dispatch,
     error,
     extensions,
+    hg,
     localrepo,
     node,
     util,
 )
 
+from . import share
+
 cmdtable = {}
 command = cmdutil.command(cmdtable)
 
@@ -46,6 +50,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):
@@ -55,6 +64,8 @@ 
         dirstate.dirstate, '_writedirstate', recorddirstateparents)
     extensions.wrapfunction(
         localrepo.localrepository.dirstate, 'func', wrapdirstate)
+    extensions.wrapfunction(hg, 'postshare', wrappostshare)
+    extensions.wrapcommand(share.cmdtable, 'unshare', unsharejournal)
 
 def reposetup(ui, repo):
     if repo.local():
@@ -110,6 +121,80 @@ 
                 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
+    if not iterable_map:
+        # all iterables where empty
+        return
+
+    while True:
+        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]
+            if not iterable_map:
+                # all iterables are empty
+                return
+
+def wrappostshare(orig, sourcerepo, destrepo, **kwargs):
+    orig(sourcerepo, destrepo, **kwargs)
+    with destrepo.vfs('shared', 'a') as fp:
+        fp.write('journal\n')
+
+def unsharejournal(orig, ui, repo):
+    # do the work *before* the unshare command does it, as otherwise
+    # we no longer have access to the source repo. We also can't wrap
+    # copystore as we need a wlock while unshare takes the store lock.
+    if 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, filename='journal.bak', _newestfirst=False)
+            shared = (
+                e for e in storage._open(sharedrepo, _newestfirst=False)
+                if sharednamespaces.get(e.namespace) in sharedfeatures)
+            for entry in _mergeentriesiter(local, shared, order=min):
+                storage._write(repo, entry)
+
+    return orig(ui, repo)
+
 class journalentry(collections.namedtuple(
         'journalentry',
         'timestamp user command namespace name oldhashes newhashes')):
@@ -153,6 +238,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.
 
@@ -164,7 +253,6 @@ 
     def __init__(self, repo):
         self.repo = repo
         self.user = util.getuser()
-        self.vfs = repo.vfs
 
     # track the current command for recording in journal entries
     @property
@@ -183,6 +271,10 @@ 
         # with a non-local repo (cloning for example).
         cls._currentcommand = fullargs
 
+    @util.propertycache
+    def sharedfeatures(self):
+        return _readsharedfeatures(self.repo)
+
     def record(self, namespace, name, oldhashes, newhashes):
         """Record a new journal entry
 
@@ -203,14 +295,24 @@ 
         entry = journalentry(
             util.makedate(), self.user, self.command, namespace, name,
             oldhashes, newhashes)
-        self._write(entry)
 
-    def _write(self, entry, _wait=False):
+        repo = self.repo
+        if self.repo.shared() and 'journal' in self.sharedfeatures:
+            # write to the shared repository if this feature is being
+            # shared between working copies.
+            if sharednamespaces.get(namespace) in self.sharedfeatures:
+                srcrepo = share._getsrcrepo(repo)
+                if srcrepo is not None:
+                    repo = srcrepo
+
+        self._write(repo, entry)
+
+    def _write(self, repo, entry, _wait=False):
         try:
-            with self.repo.wlock(wait=_wait):
+            with repo.wlock(wait=_wait):
                 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 repo.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)
@@ -220,7 +322,7 @@ 
                         # not write anything) if this is not a version we can
                         # handle or the file is corrupt. In future, perhaps
                         # rotate the file instead?
-                        self.repo.ui.warn(
+                        repo.ui.warn(
                             _("unsupported journal file version '%s'\n") %
                             version)
                         return
@@ -230,16 +332,16 @@ 
                     f.seek(0, os.SEEK_END)
                     f.write(str(entry) + '\0')
         except error.LockHeld as lockerr:
-            lock = self.repo._wlockref and self.repo._wlockref()
+            lock = repo._wlockref and repo._wlockref()
             if lock and lockerr.locker == '%s:%s' % (lock._host, lock.pid):
                 # the dirstate can be written out during wlock unlock, before
                 # the lockfile is removed. Re-run the write as a postrelease
                 # function instead.
                 lock.postrelease.append(
-                    lambda: self._write(entry, _wait=True))
+                    lambda: self._write(repo, entry, _wait=True))
             else:
                 # another process holds the lock, retry and wait
-                self._write(entry, _wait=True)
+                self._write(repo, entry, _wait=True)
 
     def filtered(self, namespace=None, name=None):
         """Yield all journal entries with the given namespace or name
@@ -261,10 +363,26 @@ 
         Yields journalentry instances for each contained journal record.
 
         """
-        if not self.vfs.exists('journal'):
+        local = self._open(self.repo)
+
+        if not self.repo.shared() or 'journal' not in self.sharedfeatures:
+            return local
+        sharedrepo = share._getsrcrepo(self.repo)
+        if sharedrepo 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(sharedrepo)
+            if sharednamespaces.get(e.namespace) in self.sharedfeatures)
+        return _mergeentriesiter(local, shared)
+
+    def _open(self, repo, filename='journal', _newestfirst=True):
+        if not repo.vfs.exists(filename):
             return
 
-        with self.vfs('journal') as f:
+        with repo.vfs(filename) as f:
             raw = f.read()
 
         lines = raw.split('\0')
@@ -273,8 +391,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
diff --git a/tests/test-journal-share.t b/tests/test-journal-share.t
new file mode 100644
--- /dev/null
+++ b/tests/test-journal-share.t
@@ -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