view mercurial/archival.py @ 31765:264baeef3588

show: new extension for displaying various repository data Currently, Mercurial has a number of commands to show information. And, there are features coming down the pipe that will introduce more commands for showing information. Currently, when introducing a new class of data or a view that we wish to expose to the user, the strategy is to introduce a new command or overload an existing command, sometimes both. For example, there is a desire to formalize the wip/smartlog/underway/mine functionality that many have devised. There is also a desire to introduce a "topics" concept. Others would like views of "the current stack." In the current model, we'd need a new command for wip/smartlog/etc (that behaves a lot like a pre-defined alias of `hg log`). For topics, we'd likely overload `hg topic[s]` to both display and manipulate topics. Adding new commands for every pre-defined query doesn't scale well and pollutes `hg help`. Overloading commands to perform read-only and write operations is arguably an UX anti-pattern: while having all functionality for a given concept in one command is nice, having a single command doing multiple discrete operations is not. Furthermore, a user may be surprised that a command they thought was read-only actually changes something. We discussed this at the Mercurial 4.0 Sprint in Paris and decided that having a single command where we could hang pre-defined views of various data would be a good idea. Having such a command would: * Help prevent an explosion of new query-related commands * Create a clear separation between read and write operations (mitigates footguns) * Avoids overloading the meaning of commands that manipulate data (bookmark, tag, branch, etc) (while we can't take away the existing behavior for BC reasons, we now won't introduce this behavior on new commands) * Allows users to discover informational views more easily by aggregating them in a single location * Lowers the barrier to creating the new views (since the barrier to creating a top-level command is relatively high) So, this commit introduces the `hg show` command via the "show" extension. This command accepts a positional argument of the "view" to show. New views can be registered with a decorator. To prove it works, we implement the "bookmarks" view, which shows a table of bookmarks and their associated nodes. We introduce a new style to hold everything used by `hg show`. For our initial bookmarks view, the output varies from `hg bookmarks`: * Padding is performed in the template itself as opposed to Python * Revision integers are not shown * shortest() is used to display a 5 character node by default (as opposed to static 12 characters) I chose to implement the "bookmarks" view first because it is simple and shouldn't invite too much bikeshedding that detracts from the evaluation of `hg show` itself. But there is an important point to consider: we now have 2 ways to show a list of bookmarks. I'm not a fan of introducing multiple ways to do very similar things. So it might be worth discussing how we wish to tackle this issue for bookmarks, tags, branches, MQ series, etc. I also made the choice of explicitly declaring the default show template not part of the standard BC guarantees. History has shown that we make mistakes and poor choices with output formatting but can't fix these mistakes later because random tools are parsing output and we don't want to break these tools. Optimizing for human consumption is one of my goals for `hg show`. So, by not covering the formatting as part of BC, the barrier to future change is much lower and humans benefit. There are some improvements that can be made to formatting. For example, we don't yet use label() in the templates. We obviously want this for color. But I'm not sure if we should reuse the existing log.* labels or invent new ones. I figure we can punt that to a follow-up. At the aforementioned Sprint, we discussed and discarded various alternatives to `hg show`. We considered making `hg log <view>` perform this behavior. The main reason we can't do this is because a positional argument to `hg log` can be a file path and if there is a conflict between a path name and a view name, behavior is ambiguous. We could have introduced `hg log --view` or similar, but we felt that required too much typing (we don't want to require a command flag to show a view) and wasn't very discoverable. Furthermore, `hg log` is optimized for showing changelog data and there are things that `hg display` could display that aren't changelog centric. There were concerns about using "show" as the command name. Some users already have a "show" alias that is similar to `hg export`. There were also concerns that Git users adapted to `git show` would be confused by `hg show`'s different behavior. The main difference here is `git show` prints an `hg export` like view of the current commit by default and `hg show` requires an argument. `git show` can also display any Git object. `git show` does not support displaying more complex views: just single objects. If we implemented `hg show <hash>` or `hg show <identifier>`, `hg show` would be a superset of `git show`. Although, I'm hesitant to do that at this time because I view `hg show` as a higher-level querying command and there are namespace collisions between valid identifiers and registered views. There is also a prefix collision with `hg showconfig`, which is an alias of `hg config`. We also considered `hg view`, but that is already used by the "hgk" extension. `hg display` was also proposed at one point. It has a prefix collision with `hg diff`. General consensus was "show" or "view" are the best verbs. And since "view" was taken, "show" was chosen. There are a number of inline TODOs in this patch. Some of these represent decisions yet to be made. Others represent features requiring non-trivial complexity. Rather than bloat the patch or invite additional bikeshedding, I figured I'd document future enhancements via TODO so we can get a minimal implmentation landed. Something is better than nothing.
author Gregory Szorc <gregory.szorc@gmail.com>
date Fri, 24 Mar 2017 19:19:00 -0700
parents 7feab0e7702d
children 3047167733dc
line wrap: on
line source

# archival.py - revision archival for mercurial
#
# Copyright 2006 Vadim Gelfer <vadim.gelfer@gmail.com>
#
# This software may be used and distributed according to the terms of the
# GNU General Public License version 2 or any later version.

from __future__ import absolute_import

import gzip
import os
import struct
import tarfile
import time
import zipfile
import zlib

from .i18n import _

from . import (
    cmdutil,
    encoding,
    error,
    match as matchmod,
    util,
    vfs as vfsmod,
)
stringio = util.stringio

# from unzip source code:
_UNX_IFREG = 0x8000
_UNX_IFLNK = 0xa000

def tidyprefix(dest, kind, prefix):
    '''choose prefix to use for names in archive.  make sure prefix is
    safe for consumers.'''

    if prefix:
        prefix = util.normpath(prefix)
    else:
        if not isinstance(dest, str):
            raise ValueError('dest must be string if no prefix')
        prefix = os.path.basename(dest)
        lower = prefix.lower()
        for sfx in exts.get(kind, []):
            if lower.endswith(sfx):
                prefix = prefix[:-len(sfx)]
                break
    lpfx = os.path.normpath(util.localpath(prefix))
    prefix = util.pconvert(lpfx)
    if not prefix.endswith('/'):
        prefix += '/'
    # Drop the leading '.' path component if present, so Windows can read the
    # zip files (issue4634)
    if prefix.startswith('./'):
        prefix = prefix[2:]
    if prefix.startswith('../') or os.path.isabs(lpfx) or '/../' in prefix:
        raise error.Abort(_('archive prefix contains illegal components'))
    return prefix

exts = {
    'tar': ['.tar'],
    'tbz2': ['.tbz2', '.tar.bz2'],
    'tgz': ['.tgz', '.tar.gz'],
    'zip': ['.zip'],
    }

def guesskind(dest):
    for kind, extensions in exts.iteritems():
        if any(dest.endswith(ext) for ext in extensions):
            return kind
    return None

def _rootctx(repo):
    # repo[0] may be hidden
    for rev in repo:
        return repo[rev]
    return repo['null']

def buildmetadata(ctx):
    '''build content of .hg_archival.txt'''
    repo = ctx.repo()
    hex = ctx.hex()
    if ctx.rev() is None:
        hex = ctx.p1().hex()
        if ctx.dirty():
            hex += '+'

    base = 'repo: %s\nnode: %s\nbranch: %s\n' % (
        _rootctx(repo).hex(), hex, encoding.fromlocal(ctx.branch()))

    tags = ''.join('tag: %s\n' % t for t in ctx.tags()
                   if repo.tagtype(t) == 'global')
    if not tags:
        repo.ui.pushbuffer()
        opts = {'template': '{latesttag}\n{latesttagdistance}\n'
                            '{changessincelatesttag}',
                'style': '', 'patch': None, 'git': None}
        cmdutil.show_changeset(repo.ui, repo, opts).show(ctx)
        ltags, dist, changessince = repo.ui.popbuffer().split('\n')
        ltags = ltags.split(':')
        tags = ''.join('latesttag: %s\n' % t for t in ltags)
        tags += 'latesttagdistance: %s\n' % dist
        tags += 'changessincelatesttag: %s\n' % changessince

    return base + tags

class tarit(object):
    '''write archive to tar file or stream.  can write uncompressed,
    or compress with gzip or bzip2.'''

    class GzipFileWithTime(gzip.GzipFile):

        def __init__(self, *args, **kw):
            timestamp = None
            if 'timestamp' in kw:
                timestamp = kw.pop('timestamp')
            if timestamp is None:
                self.timestamp = time.time()
            else:
                self.timestamp = timestamp
            gzip.GzipFile.__init__(self, *args, **kw)

        def _write_gzip_header(self):
            self.fileobj.write('\037\213')             # magic header
            self.fileobj.write('\010')                 # compression method
            fname = self.name
            if fname and fname.endswith('.gz'):
                fname = fname[:-3]
            flags = 0
            if fname:
                flags = gzip.FNAME
            self.fileobj.write(chr(flags))
            gzip.write32u(self.fileobj, long(self.timestamp))
            self.fileobj.write('\002')
            self.fileobj.write('\377')
            if fname:
                self.fileobj.write(fname + '\000')

    def __init__(self, dest, mtime, kind=''):
        self.mtime = mtime
        self.fileobj = None

        def taropen(mode, name='', fileobj=None):
            if kind == 'gz':
                mode = mode[0]
                if not fileobj:
                    fileobj = open(name, mode + 'b')
                gzfileobj = self.GzipFileWithTime(name, mode + 'b',
                                                  zlib.Z_BEST_COMPRESSION,
                                                  fileobj, timestamp=mtime)
                self.fileobj = gzfileobj
                return tarfile.TarFile.taropen(name, mode, gzfileobj)
            else:
                return tarfile.open(name, mode + kind, fileobj)

        if isinstance(dest, str):
            self.z = taropen('w:', name=dest)
        else:
            self.z = taropen('w|', fileobj=dest)

    def addfile(self, name, mode, islink, data):
        i = tarfile.TarInfo(name)
        i.mtime = self.mtime
        i.size = len(data)
        if islink:
            i.type = tarfile.SYMTYPE
            i.mode = 0o777
            i.linkname = data
            data = None
            i.size = 0
        else:
            i.mode = mode
            data = stringio(data)
        self.z.addfile(i, data)

    def done(self):
        self.z.close()
        if self.fileobj:
            self.fileobj.close()

class tellable(object):
    '''provide tell method for zipfile.ZipFile when writing to http
    response file object.'''

    def __init__(self, fp):
        self.fp = fp
        self.offset = 0

    def __getattr__(self, key):
        return getattr(self.fp, key)

    def write(self, s):
        self.fp.write(s)
        self.offset += len(s)

    def tell(self):
        return self.offset

class zipit(object):
    '''write archive to zip file or stream.  can write uncompressed,
    or compressed with deflate.'''

    def __init__(self, dest, mtime, compress=True):
        if not isinstance(dest, str):
            try:
                dest.tell()
            except (AttributeError, IOError):
                dest = tellable(dest)
        self.z = zipfile.ZipFile(dest, 'w',
                                 compress and zipfile.ZIP_DEFLATED or
                                 zipfile.ZIP_STORED)

        # Python's zipfile module emits deprecation warnings if we try
        # to store files with a date before 1980.
        epoch = 315532800 # calendar.timegm((1980, 1, 1, 0, 0, 0, 1, 1, 0))
        if mtime < epoch:
            mtime = epoch

        self.mtime = mtime
        self.date_time = time.gmtime(mtime)[:6]

    def addfile(self, name, mode, islink, data):
        i = zipfile.ZipInfo(name, self.date_time)
        i.compress_type = self.z.compression
        # unzip will not honor unix file modes unless file creator is
        # set to unix (id 3).
        i.create_system = 3
        ftype = _UNX_IFREG
        if islink:
            mode = 0o777
            ftype = _UNX_IFLNK
        i.external_attr = (mode | ftype) << 16
        # add "extended-timestamp" extra block, because zip archives
        # without this will be extracted with unexpected timestamp,
        # if TZ is not configured as GMT
        i.extra += struct.pack('<hhBl',
                               0x5455,     # block type: "extended-timestamp"
                               1 + 4,      # size of this block
                               1,          # "modification time is present"
                               int(self.mtime)) # last modification (UTC)
        self.z.writestr(i, data)

    def done(self):
        self.z.close()

class fileit(object):
    '''write archive as files in directory.'''

    def __init__(self, name, mtime):
        self.basedir = name
        self.opener = vfsmod.vfs(self.basedir)

    def addfile(self, name, mode, islink, data):
        if islink:
            self.opener.symlink(data, name)
            return
        f = self.opener(name, "w", atomictemp=True)
        f.write(data)
        f.close()
        destfile = os.path.join(self.basedir, name)
        os.chmod(destfile, mode)

    def done(self):
        pass

archivers = {
    'files': fileit,
    'tar': tarit,
    'tbz2': lambda name, mtime: tarit(name, mtime, 'bz2'),
    'tgz': lambda name, mtime: tarit(name, mtime, 'gz'),
    'uzip': lambda name, mtime: zipit(name, mtime, False),
    'zip': zipit,
    }

def archive(repo, dest, node, kind, decode=True, matchfn=None,
            prefix='', mtime=None, subrepos=False):
    '''create archive of repo as it was at node.

    dest can be name of directory, name of archive file, or file
    object to write archive to.

    kind is type of archive to create.

    decode tells whether to put files through decode filters from
    hgrc.

    matchfn is function to filter names of files to write to archive.

    prefix is name of path to put before every archive member.'''

    if kind == 'files':
        if prefix:
            raise error.Abort(_('cannot give prefix when archiving to files'))
    else:
        prefix = tidyprefix(dest, kind, prefix)

    def write(name, mode, islink, getdata):
        data = getdata()
        if decode:
            data = repo.wwritedata(name, data)
        archiver.addfile(prefix + name, mode, islink, data)

    if kind not in archivers:
        raise error.Abort(_("unknown archive type '%s'") % kind)

    ctx = repo[node]
    archiver = archivers[kind](dest, mtime or ctx.date()[0])

    if repo.ui.configbool("ui", "archivemeta", True):
        name = '.hg_archival.txt'
        if not matchfn or matchfn(name):
            write(name, 0o644, False, lambda: buildmetadata(ctx))

    if matchfn:
        files = [f for f in ctx.manifest().keys() if matchfn(f)]
    else:
        files = ctx.manifest().keys()
    total = len(files)
    if total:
        files.sort()
        repo.ui.progress(_('archiving'), 0, unit=_('files'), total=total)
        for i, f in enumerate(files):
            ff = ctx.flags(f)
            write(f, 'x' in ff and 0o755 or 0o644, 'l' in ff, ctx[f].data)
            repo.ui.progress(_('archiving'), i + 1, item=f,
                             unit=_('files'), total=total)
        repo.ui.progress(_('archiving'), None)

    if subrepos:
        for subpath in sorted(ctx.substate):
            sub = ctx.workingsub(subpath)
            submatch = matchmod.subdirmatcher(subpath, matchfn)
            total += sub.archive(archiver, prefix, submatch, decode)

    if total == 0:
        raise error.Abort(_('no files match the archive pattern'))

    archiver.done()
    return total