[PATCH] journal: new expiremental extension

Durham Goode durham at fb.com
Fri Jun 17 16:03:00 EDT 2016


Awesome! Our old internal version of this has been extremely useful for 
debugging what users did and recovering from it.  I'm looking forward to 
it being part of core.

Some minor comments inline

On 6/17/16 12:32 AM, Martijn Pieters wrote:
> # HG changeset patch
> # User Martijn Pieters <mjpieters at fb.com>
> # Date 1466145392 -3600
> #      Fri Jun 17 07:36:32 2016 +0100
> # Node ID b1bce97848c09e3e96f949d32fab67d5fbbea862
> # Parent  af849596752cc9663146160518a3125d50077f09
> journal: new expiremental extension
"experimental"
>
> 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.
>
> diff --git a/hgext/journal.py b/hgext/journal.py
> new file mode 100644
> --- /dev/null
> +++ b/hgext/journal.py
> @@ -0,0 +1,226 @@
> +# 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 os
> +
> +from mercurial.i18n import _
> +
> +from mercurial import (
> +    bookmarks,
> +    cmdutil,
> +    commands,
> +    dispatch,
> +    error,
> +    extensions,
> +    node,
> +    scmutil,
> +    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"""
> +    if util.safehasattr(repo, 'journal'):
> +        repo.journal.command = 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 journalstorage(object):
> +    def __init__(self, repo):
> +        self.repo = repo
> +        self.user = util.getuser()
> +        if repo.shared():
> +            self.vfs = scmutil.vfs(repo.sharedpath)
> +        else:
> +            self.vfs = repo.vfs
Hmm, I wonder how the journal will work with shared working copies. 
Like, we may need to store the '.' reference with information about the 
which working copy was moved.  We can figure that out later.
> +
> +    # track the current command for recording in journal entries
> +    @property
> +    def command(self):
> +        commandstr = ' '.join(map(util.shellquote, self._currentcommand))
> +        if '\n' in commandstr:
> +            # truncate multi-line commands
> +            commandstr = commandstr.partition('\n')[0] + ' ...'
> +        return commandstr
> +
> +    @command.setter
> +    def command(self, fullargs):
> +        self._currentcommand = fullargs
Seems a bit weird that the command property is not symmetrical?
> +
> +    def record(self, namespace, name, oldhashes, newhashes):
I'd add a doc comment explaining the contract of this function, so 
future people adding new journal namespaces know how to use this 
function correctly.  (for instance, call out that the hashes are 
expected to be in binary form, and that they can be a list).
> +        if not isinstance(oldhashes, list):
> +            oldhashes = [oldhashes]
> +        if not isinstance(newhashes, list):
> +            newhashes = [newhashes]
> +
> +        timestamp, tz = map(str, util.makedate())
> +        date = ' '.join((timestamp, tz))
> +        oldhashes = ','.join([node.hex(hash) for hash in oldhashes])
> +        newhashes = ','.join([node.hex(hash) for hash in newhashes])
> +        data = '\n'.join((
> +            date, self.user, self.command, namespace, name, oldhashes,
> +            newhashes))
> +
> +        with self.repo.wlock():
> +            version = None
> +            with self.vfs('journal', mode='a+b') as f:
I wonder if we will regret naming this file the same as the store 
journal.  I don't have a good alternative though.
> +                f.seek(0, os.SEEK_SET)
> +                version = f.read(5).partition('\0')[0]
> +                if version and version != str(storage_version):
> +                    # different version of the storage.  Since there have
> +                    # been no previous versions, just abort, as this can
> +                    # only mean the file is corrupt.
> +                    self.repo.ui.warn(
> +                        _("unknown 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(data + '\0')
> +
> +    def filtered(self, namespace=None, name=None):
> +        for entry in self:
> +            entry_ns, entry_name = entry[3:5]
> +            if namespace is not None and entry_ns != namespace:
> +                continue
> +            if name is not None and entry_name != name:
> +                continue
> +            yield entry
> +
> +    def __iter__(self):
> +        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
> +            parts = tuple(line.split('\n'))
> +            timestamp, tz = parts[0].split()
> +            timestamp, tz = float(timestamp), int(tz)
> +            oldhashes, newhashes = parts[-2:]
> +            oldhashes = oldhashes.split(',')
> +            newhashes = newhashes.split(',')
> +            yield ((timestamp, tz),) + parts[1:-2] + (oldhashes, newhashes)
> +
> +# journal reading
> + at command(
> +    'journal', [
> +        ('c', 'commits', None, 'show commit metadata'),
> +    ] + commands.logopts, '[OPTION]... [BOOKMARKNAME]')
> +def journal(ui, repo, *args, **opts):
> +    """show the previous position of bookmarks
> +
> +    The journal is used to see the previous commits that bookmarks. By default
s/that bookmarks/of bookmarks/
> +    the previous locations for all bookmarks are shown.  Passing a bookmark
> +    name will show all the previous positions of that bookmark.
> +
> +    By default the reflog only shows the commit hash and the command that was
s/reflog/journal/
> +    running at that time. -v/--verbose will show the prior hash, the user, and
> +    the time at which it happened.
> +
> +    Use in -c/--commits to output log information on each commit hash.
> +
> +    `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'
I think this needs localization _() around it.

Also, if we add working copy tracking later, we might change this 
behavior to default to the history of the working copy.  I don't know if 
that would constitute a BC change since this extension is experimental.
> +        else:
> +            name = "'%s'" % bookmarkname
> +        ui.status(_("Previous locations of %s:\n") % name)
> +
> +    entry = None
> +    for count, entry in enumerate(repo.journal.filtered(name=bookmarkname)):
> +        timestamp, user, command, namespace, name, oldhashes, newhashes = entry
> +        newhashesstr = ','.join([hash[:12] for hash in newhashes])
> +        oldhashesstr = ','.join([hash[:12] for hash in oldhashes])
> +
> +        fm.startitem()
> +        fm.condwrite(ui.verbose, 'oldhashes', '%s -> ', oldhashesstr)
> +        fm.write('newhashes', '%s', newhashesstr)
> +        fm.condwrite(ui.verbose, 'user', ' %s', user.ljust(8))
> +
> +        timestring = util.datestr(timestamp, '%Y-%m-%d %H:%M %1%2')
> +        fm.condwrite(ui.verbose, 'date', ' %s', timestring)
> +        fm.write('command', '  %s\n', command)
> +
> +        if opts.get("commits"):
> +            displayer = cmdutil.show_changeset(ui, repo, opts, buffered=False)
> +            for hash in 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"))


More information about the Mercurial-devel mailing list