Patchwork [V3] journal: new experimental extension

login
register
mail settings
Submitter Martijn Pieters
Date June 24, 2016, 3:30 p.m.
Message ID <4653159c0dc01e75ea4f.1466782220@mjpieters-mbp.dhcp.thefacebook.com>
Download mbox | patch
Permalink /patch/15601/
State Accepted
Delegated to: Yuya Nishihara
Headers show

Comments

Martijn Pieters - June 24, 2016, 3:30 p.m.
# HG changeset patch
# User Martijn Pieters <mjpieters@fb.com>
# Date 1466781125 -3600
#      Fri Jun 24 16:12:05 2016 +0100
# Node ID 4653159c0dc01e75ea4f9a1825fa6e511e5bce89
# Parent  d0ae5b8f80dc115064e66e4ed1dfd848c4f7d1b0
journal: new experimental extension

Records bookmark locations and shows you where bookmarks were located in the
past.

This is the first in a planned series of locations to be recorded; a future
patch will add working copy (dirstate) tracking, and remote bookmarks will be
supported as well, so the journal storage format should be fairly generic to
support those use-cases.
Martijn Pieters - June 24, 2016, 3:34 p.m.
On 24 June 2016 at 16:30, Martijn Pieters <mj@zopatista.com> wrote:
> # HG changeset patch
> # User Martijn Pieters <mjpieters@fb.com>
> # Date 1466781125 -3600
> #      Fri Jun 24 16:12:05 2016 +0100
> # Node ID 4653159c0dc01e75ea4f9a1825fa6e511e5bce89
> # Parent  d0ae5b8f80dc115064e66e4ed1dfd848c4f7d1b0
> journal: new experimental extension
>
> Records bookmark locations and shows you where bookmarks were located in the
> past.
>
> This is the first in a planned series of locations to be recorded; a future
> patch will add working copy (dirstate) tracking, and remote bookmarks will be
> supported as well, so the journal storage format should be fairly generic to
> support those use-cases.

I have a series ready to add working copy and shared repositories too,
but these can wait until people have had their say on this.
Yuya Nishihara - June 29, 2016, 1:33 p.m.
On Fri, 24 Jun 2016 16:30:20 +0100, Martijn Pieters wrote:
> # HG changeset patch
> # User Martijn Pieters <mjpieters@fb.com>
> # Date 1466781125 -3600
> #      Fri Jun 24 16:12:05 2016 +0100
> # Node ID 4653159c0dc01e75ea4f9a1825fa6e511e5bce89
> # Parent  d0ae5b8f80dc115064e66e4ed1dfd848c4f7d1b0
> journal: new experimental extension

I agree the name "journal" is somewhat confusing, but I couldn't think of
better name. So I'm going to queue this.

I found a few nits. I'll fix them inflight if you agree.

And, can you update the wiki page?

https://www.mercurial-scm.org/wiki/ExperimentalExtensionsPlan

> +# storage format version; increment when the format changes
> +storage_version = 0

s/storage_version/storageversion/ per our coding style.

> +def runcommand(orig, lui, repo, cmd, fullargs, *args):
> +    """Track the command line options for recording in the journal"""
> +    journalstorage.recordcommand(*fullargs)
> +    return orig(lui, repo, cmd, fullargs, *args)

Maybe we'll need ui.fullargs or something, but it's beyond the scope of
this patch.

> +    def record(self, namespace, name, oldhashes, newhashes):
> +        """Record a new journal entry
> +
> +        * namespace: an opaque string; this can be used to filter on the type
> +          of recorded entries.
> +        * name: the name defining this entry; for bookmarks, this is the
> +          bookmark name. Can be filtered on when retrieving entries.
> +        * oldhashes and newhashes: each a single binary hash, or a list of
> +          binary hashes. These represent the old and new position of the named
> +          item.
> +
> +        """
> +        if not isinstance(oldhashes, list):
> +            oldhashes = [oldhashes]
> +        if not isinstance(newhashes, list):
> +            newhashes = [newhashes]
> +
> +        entry = journalentry(
> +            util.makedate(), self.user, self.command, namespace, name,
> +            oldhashes, newhashes)
> +
> +        with self.repo.wlock():
> +            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:
> +                f.seek(0, os.SEEK_SET)
> +                # Read just enough bytes to get a version number (up to 2
> +                # digits plus separator)
> +                version = f.read(3).partition('\0')[0]
> +                if version and version != str(storage_version):
> +                    # different version of the storage. Exit early (and 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(
> +                        _("unsupported journal file version '%s'\n") % version)
> +                    return
> +                if not version:
> +                    # empty file, write version first
> +                    f.write(str(storage_version) + '\0')
> +                f.seek(0, os.SEEK_END)
> +                f.write(str(entry) + '\0')

[snip]

> +    def __iter__(self):
> +        """Iterate over the storage
> +
> +        Yields journalentry instances for each contained journal record.
> +
> +        """
> +        if not self.vfs.exists('journal'):
> +            return
> +
> +        with self.repo.wlock():
> +            with self.vfs('journal') as f:
> +                raw = f.read()

No need of wlock for reading because the journal file is atomically updated.
I'll remove it.

> +# journal reading
> +# log options that don't make sense for journal
> +_ignore_opts = ('no-merges', 'graph')

s/_ignore_opts/_ignoreopts/

> +@command(
> +    'journal', [
> +        ('c', 'commits', None, 'show commit metadata'),
> +    ] + [opt for opt in commands.logopts if opt[1] not in _ignore_opts],
> +    '[OPTION]... [BOOKMARKNAME]')
> +def journal(ui, repo, *args, **opts):
> +    """show the previous position of bookmarks
> +
> +    The journal is used to see the previous commits of bookmarks. By default
> +    the previous locations for all bookmarks are shown.  Passing a bookmark
> +    name will show all the previous positions of that bookmark.
> +
> +    By default hg journal only shows the commit hash and the command that was
> +    running at that time. -v/--verbose will show the prior hash, the user, and
> +    the time at which it happened.
> +
> +    Use -c/--commits to output log information on each commit hash; at this
> +    point you can use the usual `--patch`, `--git`, `--stat` and `--template`
> +    switches to alter the log output for these.
> +
> +    `hg journal -T json` can be used to produce machine readable output.
> +
> +    """
> +    bookmarkname = None
> +    if args:
> +        bookmarkname = args[0]
> +
> +    fm = ui.formatter('journal', opts)
> +
> +    if opts.get("template") != "json":
> +        if bookmarkname is None:
> +            name = _('all bookmarks')
> +        else:
> +            name = "'%s'" % bookmarkname
> +        ui.status(_("Previous locations of %s:\n") % name)

s/Previous/previous/ for consistency.

> +    limit = cmdutil.loglimit(opts)
> +    entry = None
> +    for count, entry in enumerate(repo.journal.filtered(name=bookmarkname)):
> +        if count == limit:
> +            break
> +        newhashesstr = ','.join([node.short(hash) for hash in entry.newhashes])
> +        oldhashesstr = ','.join([node.short(hash) for hash in entry.oldhashes])
> +
> +        fm.startitem()
> +        fm.condwrite(ui.verbose, 'oldhashes', '%s -> ', oldhashesstr)
> +        fm.write('newhashes', '%s', newhashesstr)
> +        fm.condwrite(ui.verbose, 'user', ' %s', entry.user.ljust(8))
> +
> +        timestring = util.datestr(entry.timestamp, '%Y-%m-%d %H:%M %1%2')
> +        fm.condwrite(ui.verbose, 'date', ' %s', timestring)
> +        fm.write('command', '  %s\n', entry.command)
> +
> +        if opts.get("commits"):
> +            displayer = cmdutil.show_changeset(ui, repo, opts, buffered=False)
> +            for hash in entry.newhashes:
> +                try:
> +                    ctx = repo[hash]
> +                    displayer.show(ctx)
> +                except error.RepoLookupError as e:
> +                    fm.write('repolookuperror', "%s\n\n", str(e))
> +            displayer.close()
> +
> +    fm.end()
> +
> +    if entry is None:
> +        ui.status(_("no recorded locations\n"))

Formatter and templater stuffs will need rework when we settle the output
format of this command. But that won't be easy, and this is an experimental
extension, so I think it's okay to revisit the issue later.
Martijn Pieters - June 29, 2016, 6:02 p.m.
On 29 June 2016 at 14:33, Yuya Nishihara <yuya@tcha.org> wrote:
> On Fri, 24 Jun 2016 16:30:20 +0100, Martijn Pieters wrote:
>> # HG changeset patch
>> # User Martijn Pieters <mjpieters@fb.com>
>> # Date 1466781125 -3600
>> #      Fri Jun 24 16:12:05 2016 +0100
>> # Node ID 4653159c0dc01e75ea4f9a1825fa6e511e5bce89
>> # Parent  d0ae5b8f80dc115064e66e4ed1dfd848c4f7d1b0
>> journal: new experimental extension
>
> I agree the name "journal" is somewhat confusing, but I couldn't think of
> better name. So I'm going to queue this.
>
> I found a few nits. I'll fix them inflight if you agree.

That's fine, thanks!

> And, can you update the wiki page?
>
> https://www.mercurial-scm.org/wiki/ExperimentalExtensionsPlan

Done

>> +# storage format version; increment when the format changes
>> +storage_version = 0
>
> s/storage_version/storageversion/ per our coding style.
>
>> +def runcommand(orig, lui, repo, cmd, fullargs, *args):
>> +    """Track the command line options for recording in the journal"""
>> +    journalstorage.recordcommand(*fullargs)
>> +    return orig(lui, repo, cmd, fullargs, *args)
>
> Maybe we'll need ui.fullargs or something, but it's beyond the scope of
> this patch.
>
>> +    def record(self, namespace, name, oldhashes, newhashes):
>> +        """Record a new journal entry
>> +
>> +        * namespace: an opaque string; this can be used to filter on the type
>> +          of recorded entries.
>> +        * name: the name defining this entry; for bookmarks, this is the
>> +          bookmark name. Can be filtered on when retrieving entries.
>> +        * oldhashes and newhashes: each a single binary hash, or a list of
>> +          binary hashes. These represent the old and new position of the named
>> +          item.
>> +
>> +        """
>> +        if not isinstance(oldhashes, list):
>> +            oldhashes = [oldhashes]
>> +        if not isinstance(newhashes, list):
>> +            newhashes = [newhashes]
>> +
>> +        entry = journalentry(
>> +            util.makedate(), self.user, self.command, namespace, name,
>> +            oldhashes, newhashes)
>> +
>> +        with self.repo.wlock():
>> +            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:
>> +                f.seek(0, os.SEEK_SET)
>> +                # Read just enough bytes to get a version number (up to 2
>> +                # digits plus separator)
>> +                version = f.read(3).partition('\0')[0]
>> +                if version and version != str(storage_version):
>> +                    # different version of the storage. Exit early (and 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(
>> +                        _("unsupported journal file version '%s'\n") % version)
>> +                    return
>> +                if not version:
>> +                    # empty file, write version first
>> +                    f.write(str(storage_version) + '\0')
>> +                f.seek(0, os.SEEK_END)
>> +                f.write(str(entry) + '\0')
>
> [snip]
>
>> +    def __iter__(self):
>> +        """Iterate over the storage
>> +
>> +        Yields journalentry instances for each contained journal record.
>> +
>> +        """
>> +        if not self.vfs.exists('journal'):
>> +            return
>> +
>> +        with self.repo.wlock():
>> +            with self.vfs('journal') as f:
>> +                raw = f.read()
>
> No need of wlock for reading because the journal file is atomically updated.
> I'll remove it.

Ah, better without if not needed.

>> +# journal reading
>> +# log options that don't make sense for journal
>> +_ignore_opts = ('no-merges', 'graph')
>
> s/_ignore_opts/_ignoreopts/
>
>> +@command(
>> +    'journal', [
>> +        ('c', 'commits', None, 'show commit metadata'),
>> +    ] + [opt for opt in commands.logopts if opt[1] not in _ignore_opts],
>> +    '[OPTION]... [BOOKMARKNAME]')
>> +def journal(ui, repo, *args, **opts):
>> +    """show the previous position of bookmarks
>> +
>> +    The journal is used to see the previous commits of bookmarks. By default
>> +    the previous locations for all bookmarks are shown.  Passing a bookmark
>> +    name will show all the previous positions of that bookmark.
>> +
>> +    By default hg journal only shows the commit hash and the command that was
>> +    running at that time. -v/--verbose will show the prior hash, the user, and
>> +    the time at which it happened.
>> +
>> +    Use -c/--commits to output log information on each commit hash; at this
>> +    point you can use the usual `--patch`, `--git`, `--stat` and `--template`
>> +    switches to alter the log output for these.
>> +
>> +    `hg journal -T json` can be used to produce machine readable output.
>> +
>> +    """
>> +    bookmarkname = None
>> +    if args:
>> +        bookmarkname = args[0]
>> +
>> +    fm = ui.formatter('journal', opts)
>> +
>> +    if opts.get("template") != "json":
>> +        if bookmarkname is None:
>> +            name = _('all bookmarks')
>> +        else:
>> +            name = "'%s'" % bookmarkname
>> +        ui.status(_("Previous locations of %s:\n") % name)
>
> s/Previous/previous/ for consistency.
>
>> +    limit = cmdutil.loglimit(opts)
>> +    entry = None
>> +    for count, entry in enumerate(repo.journal.filtered(name=bookmarkname)):
>> +        if count == limit:
>> +            break
>> +        newhashesstr = ','.join([node.short(hash) for hash in entry.newhashes])
>> +        oldhashesstr = ','.join([node.short(hash) for hash in entry.oldhashes])
>> +
>> +        fm.startitem()
>> +        fm.condwrite(ui.verbose, 'oldhashes', '%s -> ', oldhashesstr)
>> +        fm.write('newhashes', '%s', newhashesstr)
>> +        fm.condwrite(ui.verbose, 'user', ' %s', entry.user.ljust(8))
>> +
>> +        timestring = util.datestr(entry.timestamp, '%Y-%m-%d %H:%M %1%2')
>> +        fm.condwrite(ui.verbose, 'date', ' %s', timestring)
>> +        fm.write('command', '  %s\n', entry.command)
>> +
>> +        if opts.get("commits"):
>> +            displayer = cmdutil.show_changeset(ui, repo, opts, buffered=False)
>> +            for hash in entry.newhashes:
>> +                try:
>> +                    ctx = repo[hash]
>> +                    displayer.show(ctx)
>> +                except error.RepoLookupError as e:
>> +                    fm.write('repolookuperror', "%s\n\n", str(e))
>> +            displayer.close()
>> +
>> +    fm.end()
>> +
>> +    if entry is None:
>> +        ui.status(_("no recorded locations\n"))
>
> Formatter and templater stuffs will need rework when we settle the output
> format of this command. But that won't be easy, and this is an experimental
> extension, so I think it's okay to revisit the issue later.

I've included that in the experimental extension notes on the wiki.

Thanks!
Yuya Nishihara - June 30, 2016, 11:56 a.m.
On Wed, 29 Jun 2016 19:02:24 +0100, Martijn Pieters wrote:
> On 29 June 2016 at 14:33, Yuya Nishihara <yuya@tcha.org> wrote:
> > On Fri, 24 Jun 2016 16:30:20 +0100, Martijn Pieters wrote:  
> >> # HG changeset patch
> >> # User Martijn Pieters <mjpieters@fb.com>
> >> # Date 1466781125 -3600
> >> #      Fri Jun 24 16:12:05 2016 +0100
> >> # Node ID 4653159c0dc01e75ea4f9a1825fa6e511e5bce89
> >> # Parent  d0ae5b8f80dc115064e66e4ed1dfd848c4f7d1b0
> >> journal: new experimental extension  
> >
> > I agree the name "journal" is somewhat confusing, but I couldn't think of
> > better name. So I'm going to queue this.
> >
> > I found a few nits. I'll fix them inflight if you agree.  
> 
> That's fine, thanks!
> 
> > And, can you update the wiki page?
> >
> > https://www.mercurial-scm.org/wiki/ExperimentalExtensionsPlan  
> 
> Done

Thanks, and pushed to the committed repo.

Patch

diff --git a/hgext/journal.py b/hgext/journal.py
new file mode 100644
--- /dev/null
+++ b/hgext/journal.py
@@ -0,0 +1,297 @@ 
+# journal.py
+#
+# Copyright 2014-2016 Facebook, Inc.
+#
+# This software may be used and distributed according to the terms of the
+# GNU General Public License version 2 or any later version.
+"""Track previous positions of bookmarks (EXPERIMENTAL)
+
+This extension adds a new command: `hg journal`, which shows you where
+bookmarks were previously located.
+
+"""
+
+from __future__ import absolute_import
+
+import collections
+import os
+
+from mercurial.i18n import _
+
+from mercurial import (
+    bookmarks,
+    cmdutil,
+    commands,
+    dispatch,
+    error,
+    extensions,
+    node,
+    util,
+)
+
+cmdtable = {}
+command = cmdutil.command(cmdtable)
+
+# Note for extension authors: ONLY specify testedwith = 'internal' for
+# extensions which SHIP WITH MERCURIAL. Non-mainline extensions should
+# be specifying the version(s) of Mercurial they are tested with, or
+# leave the attribute unspecified.
+testedwith = 'internal'
+
+# storage format version; increment when the format changes
+storage_version = 0
+
+# namespaces
+bookmarktype = 'bookmark'
+
+# Journal recording, register hooks and storage object
+def extsetup(ui):
+    extensions.wrapfunction(dispatch, 'runcommand', runcommand)
+    extensions.wrapfunction(bookmarks.bmstore, '_write', recordbookmarks)
+
+def reposetup(ui, repo):
+    if repo.local():
+        repo.journal = journalstorage(repo)
+
+def runcommand(orig, lui, repo, cmd, fullargs, *args):
+    """Track the command line options for recording in the journal"""
+    journalstorage.recordcommand(*fullargs)
+    return orig(lui, repo, cmd, fullargs, *args)
+
+def recordbookmarks(orig, store, fp):
+    """Records all bookmark changes in the journal."""
+    repo = store._repo
+    if util.safehasattr(repo, 'journal'):
+        oldmarks = bookmarks.bmstore(repo)
+        for mark, value in store.iteritems():
+            oldvalue = oldmarks.get(mark, node.nullid)
+            if value != oldvalue:
+                repo.journal.record(bookmarktype, mark, oldvalue, value)
+    return orig(store, fp)
+
+class journalentry(collections.namedtuple(
+        'journalentry',
+        'timestamp user command namespace name oldhashes newhashes')):
+    """Individual journal entry
+
+    * timestamp: a mercurial (time, timezone) tuple
+    * user: the username that ran the command
+    * namespace: the entry namespace, an opaque string
+    * name: the name of the changed item, opaque string with meaning in the
+      namespace
+    * command: the hg command that triggered this record
+    * oldhashes: a tuple of one or more binary hashes for the old location
+    * newhashes: a tuple of one or more binary hashes for the new location
+
+    Handles serialisation from and to the storage format. Fields are
+    separated by newlines, hashes are written out in hex separated by commas,
+    timestamp and timezone are separated by a space.
+
+    """
+    @classmethod
+    def fromstorage(cls, line):
+        (time, user, command, namespace, name,
+         oldhashes, newhashes) = line.split('\n')
+        timestamp, tz = time.split()
+        timestamp, tz = float(timestamp), int(tz)
+        oldhashes = tuple(node.bin(hash) for hash in oldhashes.split(','))
+        newhashes = tuple(node.bin(hash) for hash in newhashes.split(','))
+        return cls(
+            (timestamp, tz), user, command, namespace, name,
+            oldhashes, newhashes)
+
+    def __str__(self):
+        """String representation for storage"""
+        time = ' '.join(map(str, self.timestamp))
+        oldhashes = ','.join([node.hex(hash) for hash in self.oldhashes])
+        newhashes = ','.join([node.hex(hash) for hash in self.newhashes])
+        return '\n'.join((
+            time, self.user, self.command, self.namespace, self.name,
+            oldhashes, newhashes))
+
+class journalstorage(object):
+    """Storage for journal entries
+
+    Entries are stored with NUL bytes as separators. See the journalentry
+    class for the per-entry structure.
+
+    The file format starts with an integer version, delimited by a NUL.
+
+    """
+    _currentcommand = ()
+
+    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
+    def command(self):
+        commandstr = ' '.join(
+            map(util.shellquote, journalstorage._currentcommand))
+        if '\n' in commandstr:
+            # truncate multi-line commands
+            commandstr = commandstr.partition('\n')[0] + ' ...'
+        return commandstr
+
+    @classmethod
+    def recordcommand(cls, *fullargs):
+        """Set the current hg arguments, stored with recorded entries"""
+        # Set the current command on the class because we may have started
+        # with a non-local repo (cloning for example).
+        cls._currentcommand = fullargs
+
+    def record(self, namespace, name, oldhashes, newhashes):
+        """Record a new journal entry
+
+        * namespace: an opaque string; this can be used to filter on the type
+          of recorded entries.
+        * name: the name defining this entry; for bookmarks, this is the
+          bookmark name. Can be filtered on when retrieving entries.
+        * oldhashes and newhashes: each a single binary hash, or a list of
+          binary hashes. These represent the old and new position of the named
+          item.
+
+        """
+        if not isinstance(oldhashes, list):
+            oldhashes = [oldhashes]
+        if not isinstance(newhashes, list):
+            newhashes = [newhashes]
+
+        entry = journalentry(
+            util.makedate(), self.user, self.command, namespace, name,
+            oldhashes, newhashes)
+
+        with self.repo.wlock():
+            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:
+                f.seek(0, os.SEEK_SET)
+                # Read just enough bytes to get a version number (up to 2
+                # digits plus separator)
+                version = f.read(3).partition('\0')[0]
+                if version and version != str(storage_version):
+                    # different version of the storage. Exit early (and 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(
+                        _("unsupported journal file version '%s'\n") % version)
+                    return
+                if not version:
+                    # empty file, write version first
+                    f.write(str(storage_version) + '\0')
+                f.seek(0, os.SEEK_END)
+                f.write(str(entry) + '\0')
+
+    def filtered(self, namespace=None, name=None):
+        """Yield all journal entries with the given namespace or name
+
+        Both the namespace and the name are optional; if neither is given all
+        entries in the journal are produced.
+
+        """
+        for entry in self:
+            if namespace is not None and entry.namespace != namespace:
+                continue
+            if name is not None and entry.name != name:
+                continue
+            yield entry
+
+    def __iter__(self):
+        """Iterate over the storage
+
+        Yields journalentry instances for each contained journal record.
+
+        """
+        if not self.vfs.exists('journal'):
+            return
+
+        with self.repo.wlock():
+            with self.vfs('journal') as f:
+                raw = f.read()
+
+        lines = raw.split('\0')
+        version = lines and lines[0]
+        if version != str(storage_version):
+            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:])
+        for line in lines:
+            if not line:
+                continue
+            yield journalentry.fromstorage(line)
+
+# journal reading
+# log options that don't make sense for journal
+_ignore_opts = ('no-merges', 'graph')
+@command(
+    'journal', [
+        ('c', 'commits', None, 'show commit metadata'),
+    ] + [opt for opt in commands.logopts if opt[1] not in _ignore_opts],
+    '[OPTION]... [BOOKMARKNAME]')
+def journal(ui, repo, *args, **opts):
+    """show the previous position of bookmarks
+
+    The journal is used to see the previous commits of bookmarks. By default
+    the previous locations for all bookmarks are shown.  Passing a bookmark
+    name will show all the previous positions of that bookmark.
+
+    By default hg journal only shows the commit hash and the command that was
+    running at that time. -v/--verbose will show the prior hash, the user, and
+    the time at which it happened.
+
+    Use -c/--commits to output log information on each commit hash; at this
+    point you can use the usual `--patch`, `--git`, `--stat` and `--template`
+    switches to alter the log output for these.
+
+    `hg journal -T json` can be used to produce machine readable output.
+
+    """
+    bookmarkname = None
+    if args:
+        bookmarkname = args[0]
+
+    fm = ui.formatter('journal', opts)
+
+    if opts.get("template") != "json":
+        if bookmarkname is None:
+            name = _('all bookmarks')
+        else:
+            name = "'%s'" % bookmarkname
+        ui.status(_("Previous locations of %s:\n") % name)
+
+    limit = cmdutil.loglimit(opts)
+    entry = None
+    for count, entry in enumerate(repo.journal.filtered(name=bookmarkname)):
+        if count == limit:
+            break
+        newhashesstr = ','.join([node.short(hash) for hash in entry.newhashes])
+        oldhashesstr = ','.join([node.short(hash) for hash in entry.oldhashes])
+
+        fm.startitem()
+        fm.condwrite(ui.verbose, 'oldhashes', '%s -> ', oldhashesstr)
+        fm.write('newhashes', '%s', newhashesstr)
+        fm.condwrite(ui.verbose, 'user', ' %s', entry.user.ljust(8))
+
+        timestring = util.datestr(entry.timestamp, '%Y-%m-%d %H:%M %1%2')
+        fm.condwrite(ui.verbose, 'date', ' %s', timestring)
+        fm.write('command', '  %s\n', entry.command)
+
+        if opts.get("commits"):
+            displayer = cmdutil.show_changeset(ui, repo, opts, buffered=False)
+            for hash in entry.newhashes:
+                try:
+                    ctx = repo[hash]
+                    displayer.show(ctx)
+                except error.RepoLookupError as e:
+                    fm.write('repolookuperror', "%s\n\n", str(e))
+            displayer.close()
+
+    fm.end()
+
+    if entry is None:
+        ui.status(_("no recorded locations\n"))
diff --git a/tests/test-journal.t b/tests/test-journal.t
new file mode 100644
--- /dev/null
+++ b/tests/test-journal.t
@@ -0,0 +1,148 @@ 
+Tests for the journal extension; records bookmark locations.
+
+  $ 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=
+  > testmocks=`pwd`/testmocks.py
+  > EOF
+
+Setup repo
+
+  $ hg init repo
+  $ cd repo
+  $ echo a > a
+  $ hg commit -Aqm a
+  $ echo b > a
+  $ hg commit -Aqm b
+  $ hg up 0
+  1 files updated, 0 files merged, 0 files removed, 0 files unresolved
+
+Test empty journal
+
+  $ hg journal
+  Previous locations of all bookmarks:
+  no recorded locations
+  $ hg journal foo
+  Previous locations of 'foo':
+  no recorded locations
+
+Test that bookmarks are tracked
+
+  $ hg book -r tip bar
+  $ hg journal bar
+  Previous locations of 'bar':
+  1e6c11564562  book -r tip bar
+  $ hg book -f bar
+  $ hg journal bar
+  Previous locations of 'bar':
+  cb9a9f314b8b  book -f bar
+  1e6c11564562  book -r tip bar
+  $ hg up
+  1 files updated, 0 files merged, 0 files removed, 0 files unresolved
+  updating bookmark bar
+  $ hg journal bar
+  Previous locations of 'bar':
+  1e6c11564562  up
+  cb9a9f314b8b  book -f bar
+  1e6c11564562  book -r tip bar
+
+Test that you can list all bookmarks as well as limit the list or filter on them
+
+  $ hg book -r tip baz
+  $ hg journal
+  Previous locations of all bookmarks:
+  1e6c11564562  book -r tip baz
+  1e6c11564562  up
+  cb9a9f314b8b  book -f bar
+  1e6c11564562  book -r tip bar
+  $ hg journal --limit 2
+  Previous locations of all bookmarks:
+  1e6c11564562  book -r tip baz
+  1e6c11564562  up
+  $ hg journal baz
+  Previous locations of 'baz':
+  1e6c11564562  book -r tip baz
+  $ hg journal bar
+  Previous locations of 'bar':
+  1e6c11564562  up
+  cb9a9f314b8b  book -f bar
+  1e6c11564562  book -r tip bar
+  $ hg journal foo
+  Previous locations of 'foo':
+  no recorded locations
+
+Test that verbose and commit output work
+
+  $ hg journal --verbose
+  Previous locations of all bookmarks:
+  000000000000 -> 1e6c11564562 foobar   1970-01-01 00:00 +0000  book -r tip baz
+  cb9a9f314b8b -> 1e6c11564562 foobar   1970-01-01 00:00 +0000  up
+  1e6c11564562 -> cb9a9f314b8b foobar   1970-01-01 00:00 +0000  book -f bar
+  000000000000 -> 1e6c11564562 foobar   1970-01-01 00:00 +0000  book -r tip bar
+  $ hg journal --commit
+  Previous locations of all bookmarks:
+  1e6c11564562  book -r tip baz
+  changeset:   1:1e6c11564562
+  bookmark:    bar
+  bookmark:    baz
+  tag:         tip
+  user:        test
+  date:        Thu Jan 01 00:00:00 1970 +0000
+  summary:     b
+  
+  1e6c11564562  up
+  changeset:   1:1e6c11564562
+  bookmark:    bar
+  bookmark:    baz
+  tag:         tip
+  user:        test
+  date:        Thu Jan 01 00:00:00 1970 +0000
+  summary:     b
+  
+  cb9a9f314b8b  book -f bar
+  changeset:   0:cb9a9f314b8b
+  user:        test
+  date:        Thu Jan 01 00:00:00 1970 +0000
+  summary:     a
+  
+  1e6c11564562  book -r tip bar
+  changeset:   1:1e6c11564562
+  bookmark:    bar
+  bookmark:    baz
+  tag:         tip
+  user:        test
+  date:        Thu Jan 01 00:00:00 1970 +0000
+  summary:     b
+  
+
+Test for behaviour on unexpected storage version information
+
+  $ printf '42\0' > .hg/journal
+  $ hg journal
+  Previous locations of all bookmarks:
+  abort: unknown journal file version '42'
+  [255]
+  $ hg book -r tip doomed
+  unsupported journal file version '42'