view forest.py @ 62:5d770477eb1a

`hg fclone` now behaves much like `hg clone`
author Simon Law <simon@akoha.org>
date Mon, 27 Aug 2007 13:40:21 -0400
parents 45f4c6176e27
children 95a1f9aab6ab
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 errno
import os
import re
import shutil

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__

try:
    parseurl = hg.parseurl
except:
    parseurl = cmdutil.parseurl

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

    def normpath(path):
        if path:
            return util.normpath(path)
        else:
            return '.'

    res = {}
    paths = [self.root]
    while paths:
        path = paths.pop()
        if os.path.realpath(path) in res:
            continue
        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[os.path.realpath(root)] = root
                    if not walkhg:
                        dirs.remove(d)
                else:
                    p = os.path.join(root, d)
                    if os.path.islink(p) and os.path.abspath(p) not in res:
                        paths.append(p)
    res = res.values()
    res.sort()
    # Turn things into relative paths
    pfx = len(self.root) + 1
    return [normpath(r[pfx:]) for r in 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 die_on_numeric_revs(revs):
    """Check to ensure that the revs passed in are not numeric.

    Numeric revisions make no sense when searching a forest.  You want
    only named branches and tags.  The only special exception is
    revision -1, which occurs before the first checkin.
    """
    if revs is None:
        return
    for strrev in revs:
        try:
            intrev = int(strrev)
        except:
            continue                    # String-based revision
        if intrev == 0 and strrev.startswith("00"):
            continue                    # Revision -1
        raise util.Abort(_("numeric revision '%s'") % strrev)


def relpath(root, pathname):
    """Returns the relative path of a local pathname from a local root."""
    root = os.path.abspath(root)
    pathname = os.path.abspath(pathname)
    if root == pathname or pathname.startswith(root + os.sep):
        pathname = os.path.normpath(pathname[len(root)+1:])
    return pathname


def urltopath(url):
    if url and hg.islocal(url):
        if url.startswith("file://"):
            url = url[7:]
        elif url.startswith("file:"):
            url = url[5:]
    return url


class Forest(object):
    """Describes the state of the forest within the current repository.

    This data structure describes the Forest contained within the
    current repository.  It contains a list of Trees that describe
    each sub-repository.
    """

    class SnapshotError(ConfigParser.NoSectionError):
        pass

    class Tree(object):
        """Describe a local sub-repository within a forest."""

        class Skip(Warning):
            """Exception that signals this tree should be skipped."""
            pass

        __slots__ = ('_repo', '_root', 'revs', 'paths')

        def __init__(self, repo=None, root=None, revs=[], paths={}):
            """Create a Tree object.

            repo may be any mercurial.localrepo object
            root is the absolute path of this repo object
            rev is the desired revision for this repository, None meaning the tip
            paths is a dictionary of path aliases to real paths
            """
            self._repo = repo
            self.revs = revs
            if repo:
                self.paths = {}
                self.setrepo(repo)
                self.paths.update(paths)
            else:
                self.setroot(root)
                self.paths = paths

        def die_on_mq(self, rootpath=None):
            """Raises a util.Abort exception if self has mq patches applied."""
            if self.mq_applied():
                rpath = self.root
                if rootpath:
                    if not isinstance(rootpath, str):
                        rootpath = rootpath.root
                    rpath = relpath(rootpath, rpath)
                raise util.Abort(_("'%s' has mq patches applied") % rpath)

        def mq_applied(self):
            rpath = urltopath(self.root)
            if not hg.islocal(rpath):
                raise util.Abort(_("'%s' is not a local repository") % rpath)
            rpath = util.localpath(rpath)
            rpath = os.path.join(rpath, ".hg")
            if not os.path.isdir(rpath):
                return False
            for entry in os.listdir(rpath):
                path = os.path.join(rpath, entry)
                if (os.path.isdir(path) and
                    os.path.isfile(os.path.join(path, 'series'))):
                    try:
                        s = os.stat(os.path.join(path, "status"))
                        if s.st_size > 0:
                            return path
                    except OSError, err:
                        if err.errno != errno.ENOENT:
                            raise
            return False

        def getpath(self, paths):
            assert(type(paths) != str)
            if paths is None:
                return None
            for path in paths:
                if not hg.islocal(path):
                    return path
                result = urltopath(path)
                if os.path.isdir(result):
                    return result
                result = urltopath(self.paths.get(path, None))
                if result is not None:
                    return result
            return None

        def getrepo(self, ui=False):
            if not self._repo and self._root:
                if ui is False:
                    raise AttributeError("getrepo() requires 'ui' parameter")
                self._repo = hg.repository(ui, self._root)
            return self._repo

        def setrepo(self, repo):
            self._root = None
            self._repo = repo
            if repo.ui:
                self.paths.update(dict(repo.ui.configitems('paths')))

        def getroot(self):
            if self._repo:
                return self._repo.root
            else:
                return self._root

        def setroot(self, root):
            self._repo = None
            self._root = root

        @staticmethod
        def skip(function):
            """Decorator that turns any exception into a Forest.Tree.Skip"""
            def skipme(*args, **keywords):
                try:
                    function(*args, **keywords)
                except Exception, err:
                    raise Forest.Tree.Skip(err)
            return skipme

        @staticmethod
        def warn(function):
            """Decorator that turns any exception into a Warning"""
            def warnme(*args, **keywords):
                try:
                    function(*args, **keywords)
                except Exception, err:
                    raise Warning(err)
            return warnme

        def working_revs(self):
            """Returns the revision of the working copy."""
            ctx = self.repo.workingctx()
            parents = ctx.parents()
            return [node.hex(parents[0].node())]

        def __repr__(self):
            return ("<forest.Tree object "
                    "- repo: %s "
                    "- revs: %s "
                    "- root: %s "
                    "- paths: %s>") % (self.repo, self.revs,
                                       self.root, self.paths)

        repo = property(getrepo, setrepo, None, None)
        root = property(getroot, setroot, None, None)
        
    __slots__ = ('trees', 'snapfile')

    def __init__(self, error=None, top=None, snapfile=None, walkhg=True):
        """Create a Forest object.

        top is the mercurial.localrepo object at the top of the forest.
        snapfile is the filename of the snapshot file.
        walkhg controls if we descend into .hg directories.

        If you provide no snapfile, the top repo will be searched for
        sub-repositories.

        If you do provide a snapfile, then the snapfile will be read
        for sub-repositories and no searching of the filesystem will
        be done.  The top repository is queried for the root of all
        relative paths, but if it's missing, then the current
        directory will be assumed.
        """
        if error:
            raise AttributeError("__init__() takes only named arguments")
        self.trees = []
        self.snapfile = None
        if snapfile:
            self.snapfile = snapfile
            if top is None:
                toppath = ""
            else:
                toppath = top.root
            self.read(snapfile, toppath)
        elif top:
            self.trees.append(Forest.Tree(repo=top))
            self.scan(walkhg)

    def apply(self, ui, function, paths, opts, prehooks=[]):
        """Apply function(repo, targetpath, opts) to the entire forest.

        path is a path provided on the command line.
        function is a function that should be called for every repository.
        opts is a list of options provided to the function
        prehooks is a list of hook(tree) that are run before function()

        Useful for the vast majority of commands that scan a local
        forest and perform some command on each sub-repository.
        
        Skips a sub-repository skipping it isn't actually a repository
        or if it has mq patches applied.

        In function(), targetpath will be /-separated.  You may have
        to util.localpath() it.
        """
        opts['force'] = None                # Acting on unrelated repos is BAD
        if paths:
            # Extract revisions from # syntax in path.
            paths[0], revs = parseurl(paths[0], opts['rev'])
        else:
            revs = opts['rev']
        die_on_numeric_revs(revs)
        for tree in self.trees:
            rpath = relpath(self.top().root, tree.root)
            ui.status("[%s]\n" % rpath)
            try:
                for hook in prehooks:
                    try:
                        hook(tree)
                    except Forest.Tree.Skip:
                        raise
                    except Warning, message:
                        ui.warn(_("warning: %s\n") % message)
            except Forest.Tree.Skip, message:
                ui.warn(_("skipped: %s\n") % message)
                ui.status("\n")
                continue
            except util.Abort:
                raise
            if revs:
                opts['rev'] = revs
            else:
                opts['rev'] = tree.revs
            targetpath = paths or None
            if paths:
                targetpath = tree.getpath(paths)
                if targetpath:
                    if targetpath == paths[0] and rpath != os.curdir:
                        targetpath = '/'.join((targetpath, util.pconvert(rpath)))
            function(tree, targetpath, opts)
            ui.status("\n")

    def read(self, snapfile, toppath="."):
        """Loads the information in snapfile into this forest.

        snapfile is the filename of a snapshot file
        toppath is the path of the top of this forest
        """
        if not toppath:
            toppath = "."
        cfg = ConfigParser.RawConfigParser()
        if not cfg.read([snapfile]):
            raise util.Abort("%s: %s" % (snapfile, os.strerror(errno.ENOENT)))
        seen_root = False
        sections = {}
        for section in cfg.sections():
            if section.endswith('.paths'):
                # Compatibility with old Forest snapshot files
                paths = dict(cfg.items(section))
                section = section[:-6]
                if section in sections:
                    sections[section].paths.update(paths)
                else:
                    sections[section] = Forest.Tree(paths=paths)
            else:
                root = cfg.get(section, 'root')
                if root == '.':
                    seen_root = True
                    root = toppath
                else:
                    root = os.path.join(toppath, util.localpath(root))
                root = os.path.normpath(root)
                rev = cfg.get(section, 'revision')
                if not rev:
                    rev = []
                paths = dict([(k[5:], v)
                              for k, v in cfg.items(section)
                              if k.startswith('path')])
                if section in sections:
                    sections[section].root = root
                    sections[section].revs = [rev]
                    sections[section].paths.update(paths)
                else:
                    sections[section] = Forest.Tree(root=root,
                                                    revs=[rev],
                                                    paths=paths)
        if not seen_root:
            raise Forest.SnapshotError("Could not find 'root = .' in '%s'" %
                                       snapfile)
        self.trees = sections.values()
        self.trees.sort(key=(lambda tree: tree.root))

    def scan(self, walkhg):
        """Scans for sub-repositories within this forest.

        This method modifies this forest in-place.  It searches within the
        forest's directories and enumerates all the repositories it finds.
        """
        trees = []
        top = self.top()
        ui = top.repo.ui
        for relpath in top.repo.forests(walkhg):
            if relpath != '.':
                abspath = os.path.join(top.root, util.localpath(relpath))
                trees.append(Forest.Tree(hg.repository(ui, abspath)))
        trees.sort(key=(lambda tree: tree.root))
        trees.insert(0, Forest.Tree(hg.repository(ui, top.root)))
        self.trees = trees

    def top(self):
        """Returns the top Forest.Tree in this forest."""
        if len(self.trees):
            return self.trees[0]
        else:
            return None

    def update(self, ui=None):
        """Gets the most recent information about repos."""
        try:
            if not ui:
                ui = self.top().repo.ui
        except:
            pass
        for tree in self.trees:
            try:
                repo = hg.repository(ui, tree.root)
            except RepoError:
                repo = None
            tree.repo = repo

    def write(self, fd, oldstyle=False):
        """Writes a snapshot file to a file descriptor."""
        counter = 1
        for tree in self.trees:
            fd.write("[tree%s]\n" % counter)
            root = relpath(self.top().root, tree.root)
            if root == os.curdir:
                root = '.'
            root = util.normpath(root)
            fd.write("root = %s\n" % root)
            if tree.revs:
                fd.write("revision = %s\n" % tree.revs[0])
            else:
                fd.write("revision = None\n")
            if not oldstyle:
                for name, path in tree.paths.items():
                    fd.write("path.%s = %s\n" % (name, path))
            else:
                fd.write("\n[tree%s.paths]\n" % counter)
                for name, path in tree.paths.items():
                    fd.write("%s = %s\n" % (name, path))
            fd.write("\n")
            counter += 1


    def __repr__(self):
        return ("<forest.Forest object - trees: %s> ") % self.trees


class ForestSnapshot(object):

    __slots__ = ('forest')

    def __init__(self, snapfile=None):
        self.forest = Forest(snapfile=snapfile)

    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.
        """
        if self.forest.snapfile:
            self.forest = Forest(snapfile=self.forest.snapfile,
                                 top=toprepo)
            self.forest.update(ui)
        pfx = toprepo.url()
        for t in self.forest.trees:
            root = relpath(self.forest.top().root, t.root)
            ui.status("[%s]\n" % root)
            path = t.paths.get(pathalias, None)
            if pathalias is not None and path is None:
                ui.warn(_("skipped, no path alias '%s' defined\n\n")
                         % pathalias)
                continue
            if not t.repo:
                ui.warn(_("skipped, no valid repo found\n\n"))
            rev = None
            if t.revs:
                rev = t.revs[0]
            func(t.repo, root, path, rev, (mq_check and t.mq_applied()))
            ui.status("\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.
        """

        if self.forest.top():
            self.forest.update(ui)
        else:
            if repo:
                self.forest = Forest(top=repo)
            self.forest.scan(walkhg)
        if mq_fatal or not tip:
            for tree in self.forest.trees:
                if mq_fatal:
                    tree.die_on_mq(self.forest.top())
                if not tip:
                    tree.revs = tree.working_revs()

    def write(self, ui):
        self.forest.write(ui, oldstyle=True)


def qclone(ui, source, sroot, dest, rpath, opts):
    """Helper function to clone from a remote repository.

    source is the URL of the source of this repository
    dest is the directory of the destination
    rpath is the relative path of the destination
    opts are a list of options to be passed into the clone
    """
    ui.status("[%s]\n" % rpath)
    assert(dest is not None)
    destpfx = os.path.normpath(os.path.dirname(dest))
    if not os.path.exists(destpfx):
        os.makedirs(destpfx)
    repo = hg.repository(ui, source)
    mqdir = None
    assert(source is not None)
    if hg.islocal(source):
        Forest.Tree(repo=repo).die_on_mq(sroot)
    url = urltopath(repo.url())
    ui.note(_("cloning %s to %s\n") % (url, dest))
    commands.clone(ui, url, dest, **opts)
    repo = None


def clone(ui, source, dest=None, **opts):
    """make a clone of an existing forest of repositories

    Create a clone of an existing forest in a new directory.

    Look at the help text for the clone command for more information.
    """
    die_on_numeric_revs(opts['rev'])
    source = ui.expandpath(source) or source
    islocalsrc = hg.islocal(source)
    if islocalsrc:
        source = os.path.abspath(urltopath(source))
    if dest:
        dest = os.path.normpath(dest)
    else:
        dest = hg.defaultdest(source)
    toprepo = hg.repository(ui, source)
    forests = toprepo.forests(walkhgenabled(ui, opts['walkhg']))
    for rpath in forests:
        if rpath == '.':
            rpath = ''
        if islocalsrc:
            srcpath = source
            srcpath = os.path.join(source, util.localpath(rpath))
        else:
            srcpath = '/'.join((source, rpath))
        if rpath:
            destpath = os.path.join(dest, util.localpath(rpath))
        else:
            destpath = dest
        try:
            qclone(ui=ui,
                   source=srcpath, sroot=source,
                   dest=destpath, rpath=os.path.normpath(rpath),
                   opts=opts)
        except util.Abort, err:
            ui.warn(_("skipped: %s\n") % err)
        ui.status("\n")


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'),
             _('hg fclone [OPTION]... SOURCE [DEST]')),
        "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)'))
        }