@@ -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
new file mode 100644
@@ -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