hgforest-crew / forest.py

# 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.

"""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.
"""

version = "0.9.1"

import ConfigParser
import os

from mercurial import commands, hg, node, util
from mercurial.i18n import gettext as _
from mercurial.repo import RepoError

commands.norepo += " fclone"


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


def enumerate_repos(top=''):
    """Generate a lexicographically sorted list of repository roots."""

    dirs = ['.']
    while dirs:
        root = dirs.pop()
        entries = os.listdir(os.path.join(top, root))
        entries.sort()
        entries.reverse()
        for e in entries:
            path = os.path.join(root, e)
            if not os.path.isdir(os.path.join(top, path)):
                continue
            if e == '.hg':
                yield util.normpath(root)
            else:
                dirs.append(path)


def mq_patches_applied(rootpath):
    rootpath = os.path.join(rootpath, ".hg")
    entries = os.listdir(rootpath)
    for e in entries:
        path = os.path.join(rootpath, e)
        if e == "data" or not os.path.isdir(path):
            continue
        series = os.path.join(path, "series")
        status = os.path.join(path, "status")
        if os.path.isfile(series):
            s = os.stat(status)
            if s.st_size > 0:
                return True
    return False

def repository(ui, root):
    while os.path.islink(root):
        path = os.readlink(root)
        if not os.path.isabs(path):
            path = os.path.join(os.path.dirname(root), path)
        root = path
    return hg.repository(ui, root)


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 not 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])
            index = 0
            while True:
                index += 1
                section = "tree" + str(index)
                if not cfg.has_section(section):
                    break
                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):
        """Apply a function to trees matching a snapshot entry.

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

        repo = None
        for t in self.trees:
            root, rev, path = t.info(pathalias)
            ui.write("[%s]\n" % root)
            if path is None:
                ui.write(_("skipped, no path alias '%s' defined\n\n")
                         % pathalias)
                continue
            if repo is None:
                repo = toprepo
            else:
                try:
                    repo = repository(ui, root)
                except RepoError:
                    ui.write(_("skipped, no valid repo found\n\n"))
                    continue
            if mq_patches_applied(repo.root):
                ui.write(_("skipped, mq patches applied\n\n"))
                continue
            func(repo, path, rev)
            ui.write("\n")


    def update(self, ui, repo):
        """Update a snapshot by scanning a forest.

        If the ForestSnapshot instance to update was initialized from
        a snapshot file, this regenerate the list of trees with their
        current revisions but existing path aliases are not touched.
        """

        rootmap = {}
        self.trees = []
        for root in enumerate_repos():
            if mq_patches_applied(root):
                raise util.Abort(_("'%s' has mq patches applied") % root)
            if root != '.':
                repo = repository(ui, root)
            rev = node.hex(repo.dirstate.parents()[0])
            paths = dict(repo.ui.configitems('paths'))
            if self.rootmap.has_key(root):
                tree = self.rootmap[root]
                tree.update(rev, paths)
            else:
                tree = ForestSnapshot.Tree(root, rev, paths)
            rootmap[root] = 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, **opts):
    """Clone a local forest."""
    source = os.path.normpath(source)
    dest = os.path.normpath(dest)
    opts['rev'] = []
    roots = []
    for root in enumerate_repos(source):
        if root == '.':
            srcpath = source
            destpath = dest
        else:
            subdir = util.localpath(root)
            srcpath = os.path.join(source, subdir)
            destpath = os.path.join(dest, subdir)
        if mq_patches_applied(srcpath):
            raise util.Abort(_("'%s' has mq patches applied") % root)
        roots.append((root, srcpath, destpath))
    for root in roots:
        destpfx = os.path.dirname(root[2])
        if destpfx and not os.path.exists(destpfx):
            os.makedirs(destpfx)
        ui.write("[%s]\n" % root[0])
        commands.clone(ui, root[1], root[2], **opts)
        ui.write("\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, path, *unused):
        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, path, *unused):
        commands.push(repo.ui, repo, path, **opts)

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


def seed(ui, repo, snapshot, pathalias, **opts):
    """Populate a forest according to a snapshot file."""

    cfg = ConfigParser.RawConfigParser()
    cfg.read(snapshot)
    index = 1
    while True:
        if index > 1:
            ui.write("\n")
        index += 1
        section = 'tree' + str(index)
        if not cfg.has_section(section):
            break
        root = cfg.get(section, 'root')
        dest = util.localpath(root)
        psect = section + '.paths'
        if not cfg.has_option(psect, pathalias):
            ui.write(_("skipped, no path alias '%s' defined for tree '%s'\n") %
                    (pathalias, dest))
            continue
        source = cfg.get(psect, pathalias)
        ui.write("[%s]\n" % root)
        if os.path.exists(dest):
            ui.write(_("skipped, destination '%s' already exists\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 remote repos (<= 0.9), use
        # 'update' if necessary
        opts['rev'] = []
        commands.clone(ui, source, dest, **opts)
        if not opts['tip']:
            rev = cfg.get(section, 'revision')
            if rev and rev != node.nullid:
                repo = repository(ui, dest)
                commands.update(repo.ui, repo, node=rev)


def snapshot(ui, repo, snapfile=None):
    """Generate a new or updated forest snapshot and display it."""

    snapshot = ForestSnapshot(snapfile)
    snapshot.update(ui, repo)
    snapshot.write(ui)


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

    for root in enumerate_repos():
        mqflag = ""
        if mq_patches_applied(root):
            mqflag = " *mq*"
        ui.write("[%s]%s\n" % (root, mqflag))
        repo = repository(ui, root)
        commands.status(repo.ui, repo, *pats, **opts)
        ui.write("\n")


def trees(ui, *unused):
    """List the roots of the repositories."""

    for root in enumerate_repos():
        ui.write(root + '\n')


cmdtable = {
    "fclone" :
        (clone,
         cmd_options('clone', remove=('r',)),
         _('hg fclone [OPTIONS] SOURCE DESTINATION')),
    "fpull" :
        (pull,
         cmd_options('pull', remove=('f', 'r')),
         _('hg fpull [OPTIONS] SNAPSHOT-FILE PATH-ALIAS')),
    "fpush" :
        (push,
         cmd_options('push', remove=('f', 'r')),
         _('hg fpush [OPTIONS] SNAPSHOT-FILE PATH-ALIAS')),
    "fseed" :
        (seed,
         [('', 'tip', None,
           _("use tip instead of revisions stored in the snapshot file"))] +
         cmd_options('clone', remove=('r',)),
         _('hg fseed [OPTIONS] SNAPSHOT-FILE PATH-ALIAS')),
    "fsnap" :
        (snapshot, [],
         'hg fsnap [SNAPSHOT-FILE]'),
    "fstatus" :
        (status,
         cmd_options('status'),
         _('hg fstatus [OPTIONS]')),
    "ftrees" :
        (trees, [],
         'hg ftrees'),
}
Tip: Filter by directory path e.g. /media app.js to search for public/media/app.js.
Tip: Use camelCasing e.g. ProjME to search for ProjectModifiedEvent.java.
Tip: Filter by extension type e.g. /repo .js to search for all .js files in the /repo directory.
Tip: Separate your search with spaces e.g. /ssh pom.xml to search for src/ssh/pom.xml.
Tip: Use ↑ and ↓ arrow keys to navigate and return to view the file.
Tip: You can also navigate files with Ctrl+j (next) and Ctrl+k (previous) and view the file with Ctrl+o.
Tip: You can also navigate files with Alt+j (next) and Alt+k (previous) and view the file with Alt+o.