view forest.py @ 59:00777c6a37b6

Make --walkhg=true be the default behaviour.
author Simon Law <simon@akoha.org>
date Tue, 28 Aug 2007 15:26:17 -0400
parents d575d52a113b
children df4e33bd7149
line wrap: on
line source

# Forest, an extension to work on a set of nested Mercurial trees.
#
# Copyright (C) 2006 by Robin Farine <robin.farine@terminus.org>
#
# This software may be used and distributed according to the terms
# of the GNU General Public License, incorporated herein by reference.

# Repository path representation
#
# Repository paths stored in the filesystem representation are stored
# in variables named 'rpath'. Repository roots in the mercurial
# representation, stored in variables named 'root', are used in
# snapshot files and in command output.

"""Operations on trees with nested Mercurial repositories.

This extension provides commands that apply to a composite tree called
a forest. Some commands simply wrap standard Mercurial commands, such
as 'clone' or 'status', and others involve a snapshot file.

A snapshot file represents the state of a forest at a given time. It
has the format of a ConfigParser file and lists the trees in a forest,
each tree with the following attributes:

  root          path relative to the top-level tree
  revision      the revision the working directory is based on
  paths         a list of (alias, location) pairs

The 'fsnap' command generates or updates such a file based on a forest
in the file system. Other commands use this information to populate a
forest or to pull/push changes.


Configuration

This extension recognizes the following item in the forest
configuration section:

walkhg = (0|no|false|1|yes|true)

  Whether repositories directly under a .hg directory should be
  skipped (0|no|false) or not (1|yes|true). The default value is 0.
  Some commands accept the --walkhg command-line option to override
  the behavior selected by this item.
"""

import ConfigParser
import os
import re

from mercurial import cmdutil, commands, hg, node, util
from mercurial import localrepo, sshrepo, sshserver, httprepo, statichttprepo
from mercurial.hgweb import hgweb_mod
from mercurial.i18n import gettext as _
from mercurial.repo import RepoError

# For backwards compatibility, we need the following function definition.
# If we didn't want that, we'd have just written:
#     from mercurial.commands import 
def findcmd(ui, cmd, table):
    """Find and execute mercurial.*.findcmd(ui, cmd[, table])."""
    try:
        return findcmd.findcmd(ui, cmd, table)
    except TypeError:
        return findcmd.findcmd(ui, cmd)
try:
    findcmd.findcmd = cmdutil.findcmd
    findcmd.__doc__ = cmdutil.findcmd.__doc__
except AttributeError:
    findcmd.findcmd = commands.findcmd
    findcmd.__doc__ = commands.findcmd.__doc__

cmdtable = None

commands.norepo += " fclone fseed"


def cmd_options(ui, cmd, remove=None):
    aliases, spec = findcmd(ui, cmd, commands.table)
    res = list(spec[1])
    if remove is not None:
        res = [opt for opt in res if opt[0] not in remove]
    return res

def walkhgenabled(ui, walkhg):
    if not walkhg:
        walkhg = ui.config('forest', 'walkhg', 'true')
    try:
        res = { '0' : False, 'false' : False, 'no' : False,
                '1' : True, 'true' : True, 'yes' : True }[walkhg.lower()]
    except KeyError:
        raise util.Abort(_("invalid value for 'walkhg': %s" % walkhg))
    return res


def _localrepo_forests(self, walkhg):
    """Shim this function into mercurial.localrepo.localrepository so
    that it gives you the list of subforests.

    Return a list of roots in filesystem representation relative to
    the self repository.  This list is lexigraphically sorted.
    """

    def errhandler(err):
        if err.filename == self.root:
            raise err

    res = []
    paths = [self.root]
    while paths:
        path = paths.pop()
        for root, dirs, files in os.walk(path, onerror=errhandler):
            hgdirs = dirs[:]  # Shallow-copy to protect d from dirs.remove() 
            for d in hgdirs:
                if d == '.hg':
                    res.append(root)
                    dirs.remove(d)
                else:
                    p = os.path.join(root, d)
                    if os.path.islink(p):
                        paths.append(p)
    if walkhg:
        for root in list(res):
            hgroot = os.path.join(root, '.hg')
            for e in os.listdir(hgroot):
                path = os.path.join(hgroot, e)
                if os.path.isdir(os.path.join(path, '.hg')):
                    res.append(path)
    res.sort()
    # Turn things into relative paths
    pfx = len(self.root) + 1
    res = [util.pconvert(os.path.normpath(r[pfx:])) for r in res]
    return res

localrepo.localrepository.forests = _localrepo_forests


def _sshrepo_forests(self, walkhg):
    """Shim this function into mercurial.sshrepo.sshrepository so
    that it gives you the list of subforests.

    Return a list of roots as ssh:// URLs.
    """

    if 'forests' not in self.capabilities:
        raise util.Abort(_("Remote forests cannot be cloned because the "
                           "other repository doesn't support the forest "
                           "extension."))
    data = self.call("forests", walkhg=("", "True")[walkhg])
    return data.splitlines()

sshrepo.sshrepository.forests = _sshrepo_forests


def _sshserver_do_hello(self):
    '''the hello command returns a set of lines describing various
    interesting things about the server, in an RFC822-like format.
    Currently the only one defined is "capabilities", which
    consists of a line in the form:
    
    capabilities: space separated list of tokens
    '''

    caps = ['unbundle', 'lookup', 'changegroupsubset', 'forests']
    if self.ui.configbool('server', 'uncompressed'):
        if hasattr(self.repo, "revlogversion"):
            version = self.repo.revlogversion
        else:
            version = self.repo.changelog.version
        caps.append('stream=%d' % version)
    self.respond("capabilities: %s\n" % (' '.join(caps),))

sshserver.sshserver.do_hello = _sshserver_do_hello


def _sshserver_do_forests(self):
    """Shim this function into the sshserver so that it responds to
    the forests command.  It gives a list of roots relative to the
    self.repo repository, sorted lexigraphically.
    """
    
    key, walkhg = self.getarg()
    forests = self.repo.forests(bool(walkhg))
    self.respond("\n".join(forests))

sshserver.sshserver.do_forests = _sshserver_do_forests



def _httprepo_forests(self, walkhg):
    """Shim this function into mercurial.httprepo.httprepository so
    that it gives you the list of subforests.

    Return a list of roots as http:// URLs.
    """

    if 'forests' not in self.capabilities:
        raise util.Abort(_("Remote forests cannot be cloned because the "
                           "other repository doesn't support the forest "
                           "extension."))
    data = self.do_read("forests", walkhg=("", "True")[walkhg])
    return data.splitlines()

httprepo.httprepository.forests = _httprepo_forests


def _httpserver_do_capabilities(self, req):
    caps = ['lookup', 'changegroupsubset', 'forests']
    if self.configbool('server', 'uncompressed'):
        if hasattr(self.repo, "revlogversion"):
            version = self.repo.revlogversion
        else:
            version = self.repo.changelog.version
        caps.append('stream=%d' % version)
    # XXX: make configurable and/or share code with do_unbundle:
    unbundleversions = ['HG10GZ', 'HG10BZ', 'HG10UN']
    if unbundleversions:
        caps.append('unbundle=%s' % ','.join(unbundleversions))
    resp = ' '.join(caps)
    req.httphdr("application/mercurial-0.1", length=len(resp))
    req.write(resp)

hgweb_mod.hgweb.do_capabilities = _httpserver_do_capabilities


def _httpserver_do_forests(self, req):
    """Shim this function into the httpserver so that it responds to
    the forests command.  It gives a list of roots relative to the
    self.repo repository, sorted lexigraphically.
    """

    resp = ""
    if req.form.has_key('walkhg'):
        forests = self.repo.forests(bool(req.form['walkhg'][0]))
        resp = "\n".join(forests)
    req.httphdr("application/mercurial-0.1", length=len(resp))
    req.write(resp)


hgweb_mod.hgweb.do_forests = _httpserver_do_forests


def _statichttprepo_forests(self, walkhg):
    """Shim this function into
    mercurial.statichttprepo.statichttprepository so that it gives you
    the list of subforests.

    It depends on the fact that most directory indices have directory
    names followed by a slash.  There is no reliable way of telling
    whether a link leads into a subdirectory.

    Return a list of roots in filesystem representation relative to
    the self repository.  This list is lexigraphically sorted.
    """

    import HTMLParser
    import string
    import urllib
    import urllib2
    import urlparse

    class HtmlIndexParser(HTMLParser.HTMLParser):
        def __init__(self, ui, paths, walkhg):
            self._paths = paths
            self._ui = ui
            self._walkhg = walkhg
            self.current = None
        def handle_starttag(self, tag, attrs):
            if string.lower(tag) == "a":
                for attr in attrs:
                    if (string.lower(attr[0]) == "href" and
                        attr[1].endswith('/')):
                        link = urlparse.urlsplit(attr[1])
                        if (not self._walkhg and
                            link[2].rstrip('/').split('/')[-1] == '.hg'):
                            break
                        if not link[0] and not link[2].startswith('/'):
                            self._ui.debug(_("matched on '%s'") % attr[1])
                            self._paths.append(urlparse.urljoin(self.current,
                                                                attr[1]))

    if self._url.endswith('/'):
        url = self._url
    else:
        url = self._url + '/'

    res = []
    paths = [url]
    seen = {}

    parser = HtmlIndexParser(self.ui, paths, walkhg)
    while paths:
        path = paths.pop()
        if not seen.has_key(path):
            seen[path] = True
            parser.current = path
            index = None
            try:
                self.ui.debug(_("retrieving '%s'\n") % path)
                index = urllib2.urlopen(path)
                parser.reset()
                parser.feed(index.read())
                parser.close()
                hg_path = urlparse.urljoin(path, '.hg')
                self.ui.debug(_("retrieving '%s'\n") % hg_path)
                hg = urllib2.urlopen(hg_path)
                res.append(path)
            except urllib2.HTTPError, inst:
                pass
                #raise IOError(None, inst)
            except urllib2.URLError, inst:
                pass
                #raise IOError(None, inst.reason[1])

    res.sort()
    # Turn things into relative paths
    return [root[len(url):].rstrip('/') or "." for root in res]

statichttprepo.statichttprepository.forests = _statichttprepo_forests


tree_section_re = re.compile(r"^tree(\w+)$")

def tree_sections(cfg, withtop=True):
    """Return lexicographically sorted list of tree sections."""

    allsecs = cfg.sections()
    secs = []
    top = None
    for s in allsecs:
        if tree_section_re.match(s):
            secs.append(s)
            if cfg.get(s, "root") == ".":
                top = s
    if top is None:
        raise util.Abort(_("snapshot has no entry with root '.'"))
    secs.sort(lambda a,b: cmp(cfg.get(a, "root"), cfg.get(b, "root")))
    # ensure that '.' comes first, regardless of sort
    secs.remove(top)
    if withtop:
        secs.insert(0, top)
    return secs


def mq_patches_applied(rpath):
    if rpath.startswith("ssh:"):
        raise util.Abort(_("'%s' starts with ssh:") % rpath)
    elif rpath.startswith("http:"):
        raise util.Abort(_("'%s' starts with http:") % rpath)
    elif rpath.startswith("file:"):
        rpath = rpath[len("file:"):]
    rpath = util.localpath(rpath)
    rpath = os.path.join(rpath, ".hg")
    entries = os.listdir(rpath)
    for e in entries:
        path = os.path.join(rpath, e)
        if os.path.isdir(path):
            series = os.path.join(path, "series")
            if os.path.isfile(series):
                s = os.stat(os.path.join(path, "status"))
                if s.st_size > 0:
                    return True
    return False


class ForestSnapshot(object):

    class Tree(object):

        __slots__ = ('root', 'rev', 'paths')

        def __init__(self, root, rev, paths={}):
            self.root = root
            self.rev = rev
            self.paths = paths

        def info(self, pathalias):
            return self.root, self.rev, self.paths.get(pathalias, None)

        def update(self, rev, paths):
            self.rev = rev
            for name, path in paths.items():
                if self.paths.has_key(name):
                    self.paths[name] = path

        def write(self, ui, section):
            ui.write("root = %s\n" % self.root)
            ui.write("revision = %s\n" % self.rev)
            ui.write("\n[%s]\n" % (section + ".paths"))
            for name, path in self.paths.items():
                ui.write("%s = %s\n" % (name, path))


    __slots__ = ('rootmap', 'trees')

    def __init__(self, snapfile=None):
        self.rootmap = {}
        self.trees = []
        if snapfile is not None:
            cfg = ConfigParser.RawConfigParser()
            cfg.read([snapfile])
            for section in tree_sections(cfg):
                root = cfg.get(section, 'root')
                tree = ForestSnapshot.Tree(root, cfg.get(section, 'revision'),
                                           dict(cfg.items(section + '.paths')))
                self.rootmap[root] = tree
                self.trees.append(tree)

    def __call__(self, ui, toprepo, func, pathalias=None, mq_check=True):
        """Apply a function to trees matching a snapshot entry.

        Call func(repo, root, path, rev, mq_applied) for each repo in
        toprepo and its nested repositories where repo matches a
        snapshot entry.
        """

        repo = None
        pfx = toprepo.url()
        for t in self.trees:
            root, rev, path = t.info(pathalias)
            ui.write("[%s]\n" % root)
            if pathalias is not None and path is None:
                ui.write(_("skipped, no path alias '%s' defined\n\n")
                         % pathalias)
                continue
            if repo is None:
                repo = toprepo
            else:
                try:
                    rpath = os.path.join(pfx, util.localpath(root))
                    repo = hg.repository(ui, util.pconvert(rpath))
                except RepoError:
                    ui.write(_("skipped, no valid repo found\n\n"))
                    continue
            func(repo, root, path, rev,
                 mq_check and mq_patches_applied(repo.url()))
            ui.write("\n")


    def update(self, ui, repo, mq_fatal, walkhg='', tip=False):
        """Update a snapshot by scanning a forest.

        If the ForestSnapshot instance to update was initialized from
        a snapshot file, this regenerates the list of trees with their
        current revisions but does not add any path alias to updated
        tree entries. Newly created tree entries get all the path aliases
        from the corresponding repository.
        """

        rootmap = {}
        self.trees = []
        top = repo.url()
        if hasattr(repo, "root"):
            top = repo.root
        for relpath in repo.forests(walkhg):
            abspath = os.path.join(top, util.localpath(relpath))
            if relpath != '.':
                repo = hg.repository(ui, abspath)
            if mq_fatal and mq_patches_applied(abspath):
                raise util.Abort(_("'%s' has mq patches applied") % relpath)
            if tip:
                rev = None
            else:
                rev = node.hex(repo.dirstate.parents()[0])
            paths = dict(repo.ui.configitems('paths'))
            if self.rootmap.has_key(relpath):
                tree = self.rootmap[relpath]
                tree.update(rev, paths)
            else:
                tree = ForestSnapshot.Tree(relpath, rev, paths)
            rootmap[relpath] = tree
            self.trees.append(tree)
        self.rootmap = rootmap

    def write(self, ui):
        index = 1
        for t in self.trees:
            section = 'tree' + str(index)
            ui.write("[%s]\n" % section)
            t.write(ui, section)
            ui.write("\n")
            index += 1


def clone(ui, source, dest, walkhg, **opts):
    """Clone a forest."""
    dest = os.path.normpath(dest)

    def doit(repo, root, path, rev, *unused):
        if root == '.':
            destpath = dest
        else:
            destpath = os.path.join(dest, util.localpath(root))
            destpfx = os.path.dirname(destpath)
            if not os.path.exists(destpfx):
                os.makedirs(destpfx)
        if rev:
            opts['rev'] = [rev]
        else:
            opts['rev'] = []
        url = repo.url()
        if hasattr(repo, "root"):
            url = repo.root
        commands.clone(ui, url, destpath, **opts)

    snapshot = ForestSnapshot()
    repo = hg.repository(ui, source)
    snapshot.update(ui, repo, hasattr(repo, "root"),
                    walkhgenabled(ui, walkhg), True)
    snapshot(ui, repo, doit, mq_check=False)


def pull(ui, toprepo, snapfile, pathalias, **opts):
    """Pull changes from remote repositories to a local forest.

    Iterate over the entries in the snapshot file and, for each entry
    matching an actual tree in the forest and with a location
    associated with 'pathalias', pull changes from this location to
    the tree.

    Skip entries that do not match or trees for which there is no entry.
    """

    opts['force'] = None
    opts['rev'] = []

    def doit(repo, root, path, rev, mq_applied):
        if mq_applied:
            ui.write(_("skipped, mq patches applied\n"))
        else:
            commands.pull(repo.ui, repo, path, **opts)

    snapshot = ForestSnapshot(snapfile)
    snapshot(ui, toprepo, doit, pathalias)


def push(ui, toprepo, snapfile, pathalias, **opts):
    """Push changes in a local forest to remote destinations.

    Iterate over the entries in the snapshot file and, for each entry
    matching an actual tree in the forest and with a location
    associated with 'pathalias', push changes from this tree to the
    location.

    Skip entries that do not match or trees for which there is no entry.
    """

    opts['force'] = None
    opts['rev'] = []

    def doit(repo, root, path, rev, mq_applied):
        if mq_applied:
            ui.write(_("skipped, mq patches applied\n"))
        else:
            commands.push(repo.ui, repo, path, **opts)

    snapshot = ForestSnapshot(snapfile)
    snapshot(ui, toprepo, doit, pathalias)


def seed(ui, snapshot, pathalias='default', root='', tip=False, **opts):
    """Populate a forest according to a snapshot file."""

    cfg = ConfigParser.RawConfigParser()
    cfg.read(snapshot)
    pfx = root
    for section in tree_sections(cfg, bool(pfx)):
        root = cfg.get(section, 'root')
        ui.write("[%s]\n" % root)
        dest = os.path.normpath(os.path.join(pfx, util.localpath(root)))
        psect = section + '.paths'
        if not cfg.has_option(psect, pathalias):
            ui.write(_("skipped, no path alias '%s' defined\n\n") % pathalias)
            continue
        source = cfg.get(psect, pathalias)
        if os.path.exists(dest):
            ui.write(_("skipped, destination '%s' already exists\n\n") % dest)
            continue
        destpfx = os.path.dirname(dest)
        if destpfx and not os.path.exists(destpfx):
            os.makedirs(destpfx)
        # 'clone -r rev' not implemented for all remote repos, clone
        # everything and then use 'update' if necessary
        opts['rev'] = []
        commands.clone(ui, source, dest, **opts)
        if not tip:
            rev = cfg.get(section, 'revision')
            if rev and rev != 'tip' and rev != node.nullid:
                repo = hg.repository(ui, dest)
                commands.update(repo.ui, repo, node=rev)
        ui.write("\n")


def snapshot(ui, repo, snapfile=None, tip=False, walkhg='', **opts):
    """Generate a new or updated forest snapshot and display it."""

    snapshot = ForestSnapshot(snapfile)
    snapshot.update(ui, repo, True, walkhgenabled(ui, walkhg), tip)
    snapshot.write(ui)


def status(ui, repo, walkhg='', *pats, **opts):
    """Display the status of a forest of working directories."""

    def doit(repo, root, path, rev, mq_applied):
        if mq_applied:
            ui.write("*mq*\n")
        commands.status(repo.ui, repo, *pats, **opts)

    snapshot = ForestSnapshot()
    snapshot.update(ui, repo, False, walkhgenabled(ui, walkhg))
    snapshot(ui, repo, doit)


def trees(ui, repo, convert=False, walkhg='', **opts):
    """List the roots of the repositories."""

    walkhg = walkhgenabled(ui, walkhg)
    if convert:
        l = repo.forests(walkhg)
    else:
        root = repo.root
        l = [(f == "." and root) or os.path.join(root, f)
             for f in repo.forests(walkhg)]
    for t in l:
        ui.write(t + '\n')

def update(ui, toprepo, snapfile=None, tip=False, walkhg='', **opts):
    """Update working directories to tip or according to a snapshot file.

    When the tip option is specified, the working directory of the
    toplevel repository and of each nested repository found in the
    local filesystem is updated to its tip. When a snapshot file is
    specified, the working directory of each repository listed in the
    snapshot file is updated to the revision recorded in the snapshot.

    The tip option or the snapshot file are exclusive.
    """
    if snapfile is not None and tip or snapfile is None and not tip:
        raise util.Abort(_("need either --tip or SNAPSHOT-FILE"))
    if tip:
        snapshot = ForestSnapshot()
        snapshot.update(ui, toprepo, False, walkhgenabled(ui, walkhg), True)
    else:
        snapshot = ForestSnapshot(snapfile)

    def doit(repo, root, path, rev, mq_applied):
        if mq_applied:
            ui.write(_("skipped, mq patches applied\n"))
        else:
            commands.update(repo.ui, repo, node=rev, **opts)

    snapshot(ui, toprepo, doit)


def uisetup(ui):
    global cmdtable
    walkhgopts = ('', 'walkhg', '',
                 _("walk repositories under '.hg' (yes/no)"))
    cmdtable = {
        "fclone" :
            (clone,
             [walkhgopts] + cmd_options(ui, 'clone', remove=('r',)),
             _('hg fclone [OPTIONS] SOURCE DESTINATION')),
        "fpull" :
            (pull,
             cmd_options(ui, 'pull', remove=('f', 'r')),
             _('hg fpull [OPTIONS] SNAPSHOT-FILE PATH-ALIAS')),
        "fpush" :
            (push,
             cmd_options(ui, 'push', remove=('f', 'r')),
             _('hg fpush [OPTIONS] SNAPSHOT-FILE PATH-ALIAS')),
        "fseed" :
            (seed,
             [('', 'root', '',
               _("create root as well as children under <root>")),
              ('t', 'tip', False,
               _("use tip instead of revisions stored in the snapshot file"))]
             + cmd_options(ui, 'clone', remove=('r',)),
             _('hg fseed [OPTIONS] SNAPSHOT-FILE [PATH-ALIAS]')),
        "fsnap" :
            (snapshot,
             [('t', 'tip', False,
               _("record tip instead of actual child revisions")),
              walkhgopts],
             _('hg fsnap [OPTIONS] [SNAPSHOT-FILE]')),
        "fstatus" :
            (status,
             [walkhgopts] + cmd_options(ui, 'status'),
             _('hg fstatus [OPTIONS]')),
        "ftrees" :
            (trees,
             [('c', 'convert', False,
               _("convert paths to mercurial representation")),
              walkhgopts],
             _('hg ftrees [OPTIONS]')),
        "fupdate" :
            (update,
             [('', 'tip', False,
               _("update working directories to a specified revision")),
              walkhgopts]
             + cmd_options(ui, 'update', remove=('d',)),
             _('hg fupdate [OPTIONS] (--tip | SNAPSHOT-FILE)'))
        }