Mercurial > hg > hgforest
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)')) }