1# journal.py
2#
3# Copyright 2014-2016 Facebook, Inc.
4#
5# This software may be used and distributed according to the terms of the
6# GNU General Public License version 2 or any later version.
7"""track previous positions of bookmarks (EXPERIMENTAL)
8
9This extension adds a new command: `hg journal`, which shows you where
10bookmarks were previously located.
11
12"""
13
14from __future__ import absolute_import
15
16import collections
17import errno
18import os
19import weakref
20
21from mercurial.i18n import _
22from mercurial.node import (
23    bin,
24    hex,
25)
26
27from mercurial import (
28    bookmarks,
29    cmdutil,
30    dispatch,
31    encoding,
32    error,
33    extensions,
34    hg,
35    localrepo,
36    lock,
37    logcmdutil,
38    pycompat,
39    registrar,
40    util,
41)
42from mercurial.utils import (
43    dateutil,
44    procutil,
45    stringutil,
46)
47
48cmdtable = {}
49command = registrar.command(cmdtable)
50
51# Note for extension authors: ONLY specify testedwith = 'ships-with-hg-core' for
52# extensions which SHIP WITH MERCURIAL. Non-mainline extensions should
53# be specifying the version(s) of Mercurial they are tested with, or
54# leave the attribute unspecified.
55testedwith = b'ships-with-hg-core'
56
57# storage format version; increment when the format changes
58storageversion = 0
59
60# namespaces
61bookmarktype = b'bookmark'
62wdirparenttype = b'wdirparent'
63# In a shared repository, what shared feature name is used
64# to indicate this namespace is shared with the source?
65sharednamespaces = {
66    bookmarktype: hg.sharedbookmarks,
67}
68
69# Journal recording, register hooks and storage object
70def extsetup(ui):
71    extensions.wrapfunction(dispatch, b'runcommand', runcommand)
72    extensions.wrapfunction(bookmarks.bmstore, b'_write', recordbookmarks)
73    extensions.wrapfilecache(
74        localrepo.localrepository, b'dirstate', wrapdirstate
75    )
76    extensions.wrapfunction(hg, b'postshare', wrappostshare)
77    extensions.wrapfunction(hg, b'copystore', unsharejournal)
78
79
80def reposetup(ui, repo):
81    if repo.local():
82        repo.journal = journalstorage(repo)
83        repo._wlockfreeprefix.add(b'namejournal')
84
85        dirstate, cached = localrepo.isfilecached(repo, b'dirstate')
86        if cached:
87            # already instantiated dirstate isn't yet marked as
88            # "journal"-ing, even though repo.dirstate() was already
89            # wrapped by own wrapdirstate()
90            _setupdirstate(repo, dirstate)
91
92
93def runcommand(orig, lui, repo, cmd, fullargs, *args):
94    """Track the command line options for recording in the journal"""
95    journalstorage.recordcommand(*fullargs)
96    return orig(lui, repo, cmd, fullargs, *args)
97
98
99def _setupdirstate(repo, dirstate):
100    dirstate.journalstorage = repo.journal
101    dirstate.addparentchangecallback(b'journal', recorddirstateparents)
102
103
104# hooks to record dirstate changes
105def wrapdirstate(orig, repo):
106    """Make journal storage available to the dirstate object"""
107    dirstate = orig(repo)
108    if util.safehasattr(repo, 'journal'):
109        _setupdirstate(repo, dirstate)
110    return dirstate
111
112
113def recorddirstateparents(dirstate, old, new):
114    """Records all dirstate parent changes in the journal."""
115    old = list(old)
116    new = list(new)
117    if util.safehasattr(dirstate, 'journalstorage'):
118        # only record two hashes if there was a merge
119        oldhashes = old[:1] if old[1] == dirstate._nodeconstants.nullid else old
120        newhashes = new[:1] if new[1] == dirstate._nodeconstants.nullid else new
121        dirstate.journalstorage.record(
122            wdirparenttype, b'.', oldhashes, newhashes
123        )
124
125
126# hooks to record bookmark changes (both local and remote)
127def recordbookmarks(orig, store, fp):
128    """Records all bookmark changes in the journal."""
129    repo = store._repo
130    if util.safehasattr(repo, 'journal'):
131        oldmarks = bookmarks.bmstore(repo)
132        for mark, value in pycompat.iteritems(store):
133            oldvalue = oldmarks.get(mark, repo.nullid)
134            if value != oldvalue:
135                repo.journal.record(bookmarktype, mark, oldvalue, value)
136    return orig(store, fp)
137
138
139# shared repository support
140def _readsharedfeatures(repo):
141    """A set of shared features for this repository"""
142    try:
143        return set(repo.vfs.read(b'shared').splitlines())
144    except IOError as inst:
145        if inst.errno != errno.ENOENT:
146            raise
147        return set()
148
149
150def _mergeentriesiter(*iterables, **kwargs):
151    """Given a set of sorted iterables, yield the next entry in merged order
152
153    Note that by default entries go from most recent to oldest.
154    """
155    order = kwargs.pop('order', max)
156    iterables = [iter(it) for it in iterables]
157    # this tracks still active iterables; iterables are deleted as they are
158    # exhausted, which is why this is a dictionary and why each entry also
159    # stores the key. Entries are mutable so we can store the next value each
160    # time.
161    iterable_map = {}
162    for key, it in enumerate(iterables):
163        try:
164            iterable_map[key] = [next(it), key, it]
165        except StopIteration:
166            # empty entry, can be ignored
167            pass
168
169    while iterable_map:
170        value, key, it = order(pycompat.itervalues(iterable_map))
171        yield value
172        try:
173            iterable_map[key][0] = next(it)
174        except StopIteration:
175            # this iterable is empty, remove it from consideration
176            del iterable_map[key]
177
178
179def wrappostshare(orig, sourcerepo, destrepo, **kwargs):
180    """Mark this shared working copy as sharing journal information"""
181    with destrepo.wlock():
182        orig(sourcerepo, destrepo, **kwargs)
183        with destrepo.vfs(b'shared', b'a') as fp:
184            fp.write(b'journal\n')
185
186
187def unsharejournal(orig, ui, repo, repopath):
188    """Copy shared journal entries into this repo when unsharing"""
189    if (
190        repo.path == repopath
191        and repo.shared()
192        and util.safehasattr(repo, 'journal')
193    ):
194        sharedrepo = hg.sharedreposource(repo)
195        sharedfeatures = _readsharedfeatures(repo)
196        if sharedrepo and sharedfeatures > {b'journal'}:
197            # there is a shared repository and there are shared journal entries
198            # to copy. move shared date over from source to destination but
199            # move the local file first
200            if repo.vfs.exists(b'namejournal'):
201                journalpath = repo.vfs.join(b'namejournal')
202                util.rename(journalpath, journalpath + b'.bak')
203            storage = repo.journal
204            local = storage._open(
205                repo.vfs, filename=b'namejournal.bak', _newestfirst=False
206            )
207            shared = (
208                e
209                for e in storage._open(sharedrepo.vfs, _newestfirst=False)
210                if sharednamespaces.get(e.namespace) in sharedfeatures
211            )
212            for entry in _mergeentriesiter(local, shared, order=min):
213                storage._write(repo.vfs, entry)
214
215    return orig(ui, repo, repopath)
216
217
218class journalentry(
219    collections.namedtuple(
220        'journalentry',
221        'timestamp user command namespace name oldhashes newhashes',
222    )
223):
224    """Individual journal entry
225
226    * timestamp: a mercurial (time, timezone) tuple
227    * user: the username that ran the command
228    * namespace: the entry namespace, an opaque string
229    * name: the name of the changed item, opaque string with meaning in the
230      namespace
231    * command: the hg command that triggered this record
232    * oldhashes: a tuple of one or more binary hashes for the old location
233    * newhashes: a tuple of one or more binary hashes for the new location
234
235    Handles serialisation from and to the storage format. Fields are
236    separated by newlines, hashes are written out in hex separated by commas,
237    timestamp and timezone are separated by a space.
238
239    """
240
241    @classmethod
242    def fromstorage(cls, line):
243        (
244            time,
245            user,
246            command,
247            namespace,
248            name,
249            oldhashes,
250            newhashes,
251        ) = line.split(b'\n')
252        timestamp, tz = time.split()
253        timestamp, tz = float(timestamp), int(tz)
254        oldhashes = tuple(bin(hash) for hash in oldhashes.split(b','))
255        newhashes = tuple(bin(hash) for hash in newhashes.split(b','))
256        return cls(
257            (timestamp, tz),
258            user,
259            command,
260            namespace,
261            name,
262            oldhashes,
263            newhashes,
264        )
265
266    def __bytes__(self):
267        """bytes representation for storage"""
268        time = b' '.join(map(pycompat.bytestr, self.timestamp))
269        oldhashes = b','.join([hex(hash) for hash in self.oldhashes])
270        newhashes = b','.join([hex(hash) for hash in self.newhashes])
271        return b'\n'.join(
272            (
273                time,
274                self.user,
275                self.command,
276                self.namespace,
277                self.name,
278                oldhashes,
279                newhashes,
280            )
281        )
282
283    __str__ = encoding.strmethod(__bytes__)
284
285
286class journalstorage(object):
287    """Storage for journal entries
288
289    Entries are divided over two files; one with entries that pertain to the
290    local working copy *only*, and one with entries that are shared across
291    multiple working copies when shared using the share extension.
292
293    Entries are stored with NUL bytes as separators. See the journalentry
294    class for the per-entry structure.
295
296    The file format starts with an integer version, delimited by a NUL.
297
298    This storage uses a dedicated lock; this makes it easier to avoid issues
299    with adding entries that added when the regular wlock is unlocked (e.g.
300    the dirstate).
301
302    """
303
304    _currentcommand = ()
305    _lockref = None
306
307    def __init__(self, repo):
308        self.user = procutil.getuser()
309        self.ui = repo.ui
310        self.vfs = repo.vfs
311
312        # is this working copy using a shared storage?
313        self.sharedfeatures = self.sharedvfs = None
314        if repo.shared():
315            features = _readsharedfeatures(repo)
316            sharedrepo = hg.sharedreposource(repo)
317            if sharedrepo is not None and b'journal' in features:
318                self.sharedvfs = sharedrepo.vfs
319                self.sharedfeatures = features
320
321    # track the current command for recording in journal entries
322    @property
323    def command(self):
324        commandstr = b' '.join(
325            map(procutil.shellquote, journalstorage._currentcommand)
326        )
327        if b'\n' in commandstr:
328            # truncate multi-line commands
329            commandstr = commandstr.partition(b'\n')[0] + b' ...'
330        return commandstr
331
332    @classmethod
333    def recordcommand(cls, *fullargs):
334        """Set the current hg arguments, stored with recorded entries"""
335        # Set the current command on the class because we may have started
336        # with a non-local repo (cloning for example).
337        cls._currentcommand = fullargs
338
339    def _currentlock(self, lockref):
340        """Returns the lock if it's held, or None if it's not.
341
342        (This is copied from the localrepo class)
343        """
344        if lockref is None:
345            return None
346        l = lockref()
347        if l is None or not l.held:
348            return None
349        return l
350
351    def jlock(self, vfs):
352        """Create a lock for the journal file"""
353        if self._currentlock(self._lockref) is not None:
354            raise error.Abort(_(b'journal lock does not support nesting'))
355        desc = _(b'journal of %s') % vfs.base
356        try:
357            l = lock.lock(vfs, b'namejournal.lock', 0, desc=desc)
358        except error.LockHeld as inst:
359            self.ui.warn(
360                _(b"waiting for lock on %s held by %r\n") % (desc, inst.locker)
361            )
362            # default to 600 seconds timeout
363            l = lock.lock(
364                vfs,
365                b'namejournal.lock',
366                self.ui.configint(b"ui", b"timeout"),
367                desc=desc,
368            )
369            self.ui.warn(_(b"got lock after %s seconds\n") % l.delay)
370        self._lockref = weakref.ref(l)
371        return l
372
373    def record(self, namespace, name, oldhashes, newhashes):
374        """Record a new journal entry
375
376        * namespace: an opaque string; this can be used to filter on the type
377          of recorded entries.
378        * name: the name defining this entry; for bookmarks, this is the
379          bookmark name. Can be filtered on when retrieving entries.
380        * oldhashes and newhashes: each a single binary hash, or a list of
381          binary hashes. These represent the old and new position of the named
382          item.
383
384        """
385        if not isinstance(oldhashes, list):
386            oldhashes = [oldhashes]
387        if not isinstance(newhashes, list):
388            newhashes = [newhashes]
389
390        entry = journalentry(
391            dateutil.makedate(),
392            self.user,
393            self.command,
394            namespace,
395            name,
396            oldhashes,
397            newhashes,
398        )
399
400        vfs = self.vfs
401        if self.sharedvfs is not None:
402            # write to the shared repository if this feature is being
403            # shared between working copies.
404            if sharednamespaces.get(namespace) in self.sharedfeatures:
405                vfs = self.sharedvfs
406
407        self._write(vfs, entry)
408
409    def _write(self, vfs, entry):
410        with self.jlock(vfs):
411            # open file in amend mode to ensure it is created if missing
412            with vfs(b'namejournal', mode=b'a+b') as f:
413                f.seek(0, os.SEEK_SET)
414                # Read just enough bytes to get a version number (up to 2
415                # digits plus separator)
416                version = f.read(3).partition(b'\0')[0]
417                if version and version != b"%d" % storageversion:
418                    # different version of the storage. Exit early (and not
419                    # write anything) if this is not a version we can handle or
420                    # the file is corrupt. In future, perhaps rotate the file
421                    # instead?
422                    self.ui.warn(
423                        _(b"unsupported journal file version '%s'\n") % version
424                    )
425                    return
426                if not version:
427                    # empty file, write version first
428                    f.write((b"%d" % storageversion) + b'\0')
429                f.seek(0, os.SEEK_END)
430                f.write(bytes(entry) + b'\0')
431
432    def filtered(self, namespace=None, name=None):
433        """Yield all journal entries with the given namespace or name
434
435        Both the namespace and the name are optional; if neither is given all
436        entries in the journal are produced.
437
438        Matching supports regular expressions by using the `re:` prefix
439        (use `literal:` to match names or namespaces that start with `re:`)
440
441        """
442        if namespace is not None:
443            namespace = stringutil.stringmatcher(namespace)[-1]
444        if name is not None:
445            name = stringutil.stringmatcher(name)[-1]
446        for entry in self:
447            if namespace is not None and not namespace(entry.namespace):
448                continue
449            if name is not None and not name(entry.name):
450                continue
451            yield entry
452
453    def __iter__(self):
454        """Iterate over the storage
455
456        Yields journalentry instances for each contained journal record.
457
458        """
459        local = self._open(self.vfs)
460
461        if self.sharedvfs is None:
462            return local
463
464        # iterate over both local and shared entries, but only those
465        # shared entries that are among the currently shared features
466        shared = (
467            e
468            for e in self._open(self.sharedvfs)
469            if sharednamespaces.get(e.namespace) in self.sharedfeatures
470        )
471        return _mergeentriesiter(local, shared)
472
473    def _open(self, vfs, filename=b'namejournal', _newestfirst=True):
474        if not vfs.exists(filename):
475            return
476
477        with vfs(filename) as f:
478            raw = f.read()
479
480        lines = raw.split(b'\0')
481        version = lines and lines[0]
482        if version != b"%d" % storageversion:
483            version = version or _(b'not available')
484            raise error.Abort(_(b"unknown journal file version '%s'") % version)
485
486        # Skip the first line, it's a version number. Normally we iterate over
487        # these in reverse order to list newest first; only when copying across
488        # a shared storage do we forgo reversing.
489        lines = lines[1:]
490        if _newestfirst:
491            lines = reversed(lines)
492        for line in lines:
493            if not line:
494                continue
495            yield journalentry.fromstorage(line)
496
497
498# journal reading
499# log options that don't make sense for journal
500_ignoreopts = (b'no-merges', b'graph')
501
502
503@command(
504    b'journal',
505    [
506        (b'', b'all', None, b'show history for all names'),
507        (b'c', b'commits', None, b'show commit metadata'),
508    ]
509    + [opt for opt in cmdutil.logopts if opt[1] not in _ignoreopts],
510    b'[OPTION]... [BOOKMARKNAME]',
511    helpcategory=command.CATEGORY_CHANGE_ORGANIZATION,
512)
513def journal(ui, repo, *args, **opts):
514    """show the previous position of bookmarks and the working copy
515
516    The journal is used to see the previous commits that bookmarks and the
517    working copy pointed to. By default the previous locations for the working
518    copy.  Passing a bookmark name will show all the previous positions of
519    that bookmark. Use the --all switch to show previous locations for all
520    bookmarks and the working copy; each line will then include the bookmark
521    name, or '.' for the working copy, as well.
522
523    If `name` starts with `re:`, the remainder of the name is treated as
524    a regular expression. To match a name that actually starts with `re:`,
525    use the prefix `literal:`.
526
527    By default hg journal only shows the commit hash and the command that was
528    running at that time. -v/--verbose will show the prior hash, the user, and
529    the time at which it happened.
530
531    Use -c/--commits to output log information on each commit hash; at this
532    point you can use the usual `--patch`, `--git`, `--stat` and `--template`
533    switches to alter the log output for these.
534
535    `hg journal -T json` can be used to produce machine readable output.
536
537    """
538    opts = pycompat.byteskwargs(opts)
539    name = b'.'
540    if opts.get(b'all'):
541        if args:
542            raise error.Abort(
543                _(b"You can't combine --all and filtering on a name")
544            )
545        name = None
546    if args:
547        name = args[0]
548
549    fm = ui.formatter(b'journal', opts)
550
551    def formatnodes(nodes):
552        return fm.formatlist(map(fm.hexfunc, nodes), name=b'node', sep=b',')
553
554    if opts.get(b"template") != b"json":
555        if name is None:
556            displayname = _(b'the working copy and bookmarks')
557        else:
558            displayname = b"'%s'" % name
559        ui.status(_(b"previous locations of %s:\n") % displayname)
560
561    limit = logcmdutil.getlimit(opts)
562    entry = None
563    ui.pager(b'journal')
564    for count, entry in enumerate(repo.journal.filtered(name=name)):
565        if count == limit:
566            break
567
568        fm.startitem()
569        fm.condwrite(
570            ui.verbose, b'oldnodes', b'%s -> ', formatnodes(entry.oldhashes)
571        )
572        fm.write(b'newnodes', b'%s', formatnodes(entry.newhashes))
573        fm.condwrite(ui.verbose, b'user', b' %-8s', entry.user)
574        fm.condwrite(
575            opts.get(b'all') or name.startswith(b're:'),
576            b'name',
577            b'  %-8s',
578            entry.name,
579        )
580
581        fm.condwrite(
582            ui.verbose,
583            b'date',
584            b' %s',
585            fm.formatdate(entry.timestamp, b'%Y-%m-%d %H:%M %1%2'),
586        )
587        fm.write(b'command', b'  %s\n', entry.command)
588
589        if opts.get(b"commits"):
590            if fm.isplain():
591                displayer = logcmdutil.changesetdisplayer(ui, repo, opts)
592            else:
593                displayer = logcmdutil.changesetformatter(
594                    ui, repo, fm.nested(b'changesets'), diffopts=opts
595                )
596            for hash in entry.newhashes:
597                try:
598                    ctx = repo[hash]
599                    displayer.show(ctx)
600                except error.RepoLookupError as e:
601                    fm.plain(b"%s\n\n" % pycompat.bytestr(e))
602            displayer.close()
603
604    fm.end()
605
606    if entry is None:
607        ui.status(_(b"no recorded locations\n"))
608