diff options
Diffstat (limited to 'mercurial/subrepo.py')
-rw-r--r-- | mercurial/subrepo.py | 1273 |
1 files changed, 1273 insertions, 0 deletions
diff --git a/mercurial/subrepo.py b/mercurial/subrepo.py new file mode 100644 index 0000000..437d8b9 --- /dev/null +++ b/mercurial/subrepo.py @@ -0,0 +1,1273 @@ +# subrepo.py - sub-repository handling for Mercurial +# +# Copyright 2009-2010 Matt Mackall <mpm@selenic.com> +# +# This software may be used and distributed according to the terms of the +# GNU General Public License version 2 or any later version. + +import errno, os, re, xml.dom.minidom, shutil, posixpath +import stat, subprocess, tarfile +from i18n import _ +import config, scmutil, util, node, error, cmdutil, bookmarks, match as matchmod +hg = None +propertycache = util.propertycache + +nullstate = ('', '', 'empty') + +def state(ctx, ui): + """return a state dict, mapping subrepo paths configured in .hgsub + to tuple: (source from .hgsub, revision from .hgsubstate, kind + (key in types dict)) + """ + p = config.config() + def read(f, sections=None, remap=None): + if f in ctx: + try: + data = ctx[f].data() + except IOError, err: + if err.errno != errno.ENOENT: + raise + # handle missing subrepo spec files as removed + ui.warn(_("warning: subrepo spec file %s not found\n") % f) + return + p.parse(f, data, sections, remap, read) + else: + raise util.Abort(_("subrepo spec file %s not found") % f) + + if '.hgsub' in ctx: + read('.hgsub') + + for path, src in ui.configitems('subpaths'): + p.set('subpaths', path, src, ui.configsource('subpaths', path)) + + rev = {} + if '.hgsubstate' in ctx: + try: + for i, l in enumerate(ctx['.hgsubstate'].data().splitlines()): + l = l.lstrip() + if not l: + continue + try: + revision, path = l.split(" ", 1) + except ValueError: + raise util.Abort(_("invalid subrepository revision " + "specifier in .hgsubstate line %d") + % (i + 1)) + rev[path] = revision + except IOError, err: + if err.errno != errno.ENOENT: + raise + + def remap(src): + for pattern, repl in p.items('subpaths'): + # Turn r'C:\foo\bar' into r'C:\\foo\\bar' since re.sub + # does a string decode. + repl = repl.encode('string-escape') + # However, we still want to allow back references to go + # through unharmed, so we turn r'\\1' into r'\1'. Again, + # extra escapes are needed because re.sub string decodes. + repl = re.sub(r'\\\\([0-9]+)', r'\\\1', repl) + try: + src = re.sub(pattern, repl, src, 1) + except re.error, e: + raise util.Abort(_("bad subrepository pattern in %s: %s") + % (p.source('subpaths', pattern), e)) + return src + + state = {} + for path, src in p[''].items(): + kind = 'hg' + if src.startswith('['): + if ']' not in src: + raise util.Abort(_('missing ] in subrepo source')) + kind, src = src.split(']', 1) + kind = kind[1:] + src = src.lstrip() # strip any extra whitespace after ']' + + if not util.url(src).isabs(): + parent = _abssource(ctx._repo, abort=False) + if parent: + parent = util.url(parent) + parent.path = posixpath.join(parent.path or '', src) + parent.path = posixpath.normpath(parent.path) + joined = str(parent) + # Remap the full joined path and use it if it changes, + # else remap the original source. + remapped = remap(joined) + if remapped == joined: + src = remap(src) + else: + src = remapped + + src = remap(src) + state[util.pconvert(path)] = (src.strip(), rev.get(path, ''), kind) + + return state + +def writestate(repo, state): + """rewrite .hgsubstate in (outer) repo with these subrepo states""" + lines = ['%s %s\n' % (state[s][1], s) for s in sorted(state)] + repo.wwrite('.hgsubstate', ''.join(lines), '') + +def submerge(repo, wctx, mctx, actx, overwrite): + """delegated from merge.applyupdates: merging of .hgsubstate file + in working context, merging context and ancestor context""" + if mctx == actx: # backwards? + actx = wctx.p1() + s1 = wctx.substate + s2 = mctx.substate + sa = actx.substate + sm = {} + + repo.ui.debug("subrepo merge %s %s %s\n" % (wctx, mctx, actx)) + + def debug(s, msg, r=""): + if r: + r = "%s:%s:%s" % r + repo.ui.debug(" subrepo %s: %s %s\n" % (s, msg, r)) + + for s, l in s1.items(): + a = sa.get(s, nullstate) + ld = l # local state with possible dirty flag for compares + if wctx.sub(s).dirty(): + ld = (l[0], l[1] + "+") + if wctx == actx: # overwrite + a = ld + + if s in s2: + r = s2[s] + if ld == r or r == a: # no change or local is newer + sm[s] = l + continue + elif ld == a: # other side changed + debug(s, "other changed, get", r) + wctx.sub(s).get(r, overwrite) + sm[s] = r + elif ld[0] != r[0]: # sources differ + if repo.ui.promptchoice( + _(' subrepository sources for %s differ\n' + 'use (l)ocal source (%s) or (r)emote source (%s)?') + % (s, l[0], r[0]), + (_('&Local'), _('&Remote')), 0): + debug(s, "prompt changed, get", r) + wctx.sub(s).get(r, overwrite) + sm[s] = r + elif ld[1] == a[1]: # local side is unchanged + debug(s, "other side changed, get", r) + wctx.sub(s).get(r, overwrite) + sm[s] = r + else: + debug(s, "both sides changed, merge with", r) + wctx.sub(s).merge(r) + sm[s] = l + elif ld == a: # remote removed, local unchanged + debug(s, "remote removed, remove") + wctx.sub(s).remove() + elif a == nullstate: # not present in remote or ancestor + debug(s, "local added, keep") + sm[s] = l + continue + else: + if repo.ui.promptchoice( + _(' local changed subrepository %s which remote removed\n' + 'use (c)hanged version or (d)elete?') % s, + (_('&Changed'), _('&Delete')), 0): + debug(s, "prompt remove") + wctx.sub(s).remove() + + for s, r in sorted(s2.items()): + if s in s1: + continue + elif s not in sa: + debug(s, "remote added, get", r) + mctx.sub(s).get(r) + sm[s] = r + elif r != sa[s]: + if repo.ui.promptchoice( + _(' remote changed subrepository %s which local removed\n' + 'use (c)hanged version or (d)elete?') % s, + (_('&Changed'), _('&Delete')), 0) == 0: + debug(s, "prompt recreate", r) + wctx.sub(s).get(r) + sm[s] = r + + # record merged .hgsubstate + writestate(repo, sm) + +def _updateprompt(ui, sub, dirty, local, remote): + if dirty: + msg = (_(' subrepository sources for %s differ\n' + 'use (l)ocal source (%s) or (r)emote source (%s)?\n') + % (subrelpath(sub), local, remote)) + else: + msg = (_(' subrepository sources for %s differ (in checked out ' + 'version)\n' + 'use (l)ocal source (%s) or (r)emote source (%s)?\n') + % (subrelpath(sub), local, remote)) + return ui.promptchoice(msg, (_('&Local'), _('&Remote')), 0) + +def reporelpath(repo): + """return path to this (sub)repo as seen from outermost repo""" + parent = repo + while util.safehasattr(parent, '_subparent'): + parent = parent._subparent + p = parent.root.rstrip(os.sep) + return repo.root[len(p) + 1:] + +def subrelpath(sub): + """return path to this subrepo as seen from outermost repo""" + if util.safehasattr(sub, '_relpath'): + return sub._relpath + if not util.safehasattr(sub, '_repo'): + return sub._path + return reporelpath(sub._repo) + +def _abssource(repo, push=False, abort=True): + """return pull/push path of repo - either based on parent repo .hgsub info + or on the top repo config. Abort or return None if no source found.""" + if util.safehasattr(repo, '_subparent'): + source = util.url(repo._subsource) + if source.isabs(): + return str(source) + source.path = posixpath.normpath(source.path) + parent = _abssource(repo._subparent, push, abort=False) + if parent: + parent = util.url(util.pconvert(parent)) + parent.path = posixpath.join(parent.path or '', source.path) + parent.path = posixpath.normpath(parent.path) + return str(parent) + else: # recursion reached top repo + if util.safehasattr(repo, '_subtoppath'): + return repo._subtoppath + if push and repo.ui.config('paths', 'default-push'): + return repo.ui.config('paths', 'default-push') + if repo.ui.config('paths', 'default'): + return repo.ui.config('paths', 'default') + if abort: + raise util.Abort(_("default path for subrepository %s not found") % + reporelpath(repo)) + +def itersubrepos(ctx1, ctx2): + """find subrepos in ctx1 or ctx2""" + # Create a (subpath, ctx) mapping where we prefer subpaths from + # ctx1. The subpaths from ctx2 are important when the .hgsub file + # has been modified (in ctx2) but not yet committed (in ctx1). + subpaths = dict.fromkeys(ctx2.substate, ctx2) + subpaths.update(dict.fromkeys(ctx1.substate, ctx1)) + for subpath, ctx in sorted(subpaths.iteritems()): + yield subpath, ctx.sub(subpath) + +def subrepo(ctx, path): + """return instance of the right subrepo class for subrepo in path""" + # subrepo inherently violates our import layering rules + # because it wants to make repo objects from deep inside the stack + # so we manually delay the circular imports to not break + # scripts that don't use our demand-loading + global hg + import hg as h + hg = h + + scmutil.pathauditor(ctx._repo.root)(path) + state = ctx.substate[path] + if state[2] not in types: + raise util.Abort(_('unknown subrepo type %s') % state[2]) + return types[state[2]](ctx, path, state[:2]) + +# subrepo classes need to implement the following abstract class: + +class abstractsubrepo(object): + + def dirty(self, ignoreupdate=False): + """returns true if the dirstate of the subrepo is dirty or does not + match current stored state. If ignoreupdate is true, only check + whether the subrepo has uncommitted changes in its dirstate. + """ + raise NotImplementedError + + def basestate(self): + """current working directory base state, disregarding .hgsubstate + state and working directory modifications""" + raise NotImplementedError + + def checknested(self, path): + """check if path is a subrepository within this repository""" + return False + + def commit(self, text, user, date): + """commit the current changes to the subrepo with the given + log message. Use given user and date if possible. Return the + new state of the subrepo. + """ + raise NotImplementedError + + def remove(self): + """remove the subrepo + + (should verify the dirstate is not dirty first) + """ + raise NotImplementedError + + def get(self, state, overwrite=False): + """run whatever commands are needed to put the subrepo into + this state + """ + raise NotImplementedError + + def merge(self, state): + """merge currently-saved state with the new state.""" + raise NotImplementedError + + def push(self, opts): + """perform whatever action is analogous to 'hg push' + + This may be a no-op on some systems. + """ + raise NotImplementedError + + def add(self, ui, match, dryrun, listsubrepos, prefix, explicitonly): + return [] + + def status(self, rev2, **opts): + return [], [], [], [], [], [], [] + + def diff(self, diffopts, node2, match, prefix, **opts): + pass + + def outgoing(self, ui, dest, opts): + return 1 + + def incoming(self, ui, source, opts): + return 1 + + def files(self): + """return filename iterator""" + raise NotImplementedError + + def filedata(self, name): + """return file data""" + raise NotImplementedError + + def fileflags(self, name): + """return file flags""" + return '' + + def archive(self, ui, archiver, prefix, match=None): + if match is not None: + files = [f for f in self.files() if match(f)] + else: + files = self.files() + total = len(files) + relpath = subrelpath(self) + ui.progress(_('archiving (%s)') % relpath, 0, + unit=_('files'), total=total) + for i, name in enumerate(files): + flags = self.fileflags(name) + mode = 'x' in flags and 0755 or 0644 + symlink = 'l' in flags + archiver.addfile(os.path.join(prefix, self._path, name), + mode, symlink, self.filedata(name)) + ui.progress(_('archiving (%s)') % relpath, i + 1, + unit=_('files'), total=total) + ui.progress(_('archiving (%s)') % relpath, None) + + def walk(self, match): + ''' + walk recursively through the directory tree, finding all files + matched by the match function + ''' + pass + + def forget(self, ui, match, prefix): + return ([], []) + + def revert(self, ui, substate, *pats, **opts): + ui.warn('%s: reverting %s subrepos is unsupported\n' \ + % (substate[0], substate[2])) + return [] + +class hgsubrepo(abstractsubrepo): + def __init__(self, ctx, path, state): + self._path = path + self._state = state + r = ctx._repo + root = r.wjoin(path) + create = False + if not os.path.exists(os.path.join(root, '.hg')): + create = True + util.makedirs(root) + self._repo = hg.repository(r.ui, root, create=create) + self._initrepo(r, state[0], create) + + def _initrepo(self, parentrepo, source, create): + self._repo._subparent = parentrepo + self._repo._subsource = source + + if create: + fp = self._repo.opener("hgrc", "w", text=True) + fp.write('[paths]\n') + + def addpathconfig(key, value): + if value: + fp.write('%s = %s\n' % (key, value)) + self._repo.ui.setconfig('paths', key, value) + + defpath = _abssource(self._repo, abort=False) + defpushpath = _abssource(self._repo, True, abort=False) + addpathconfig('default', defpath) + if defpath != defpushpath: + addpathconfig('default-push', defpushpath) + fp.close() + + def add(self, ui, match, dryrun, listsubrepos, prefix, explicitonly): + return cmdutil.add(ui, self._repo, match, dryrun, listsubrepos, + os.path.join(prefix, self._path), explicitonly) + + def status(self, rev2, **opts): + try: + rev1 = self._state[1] + ctx1 = self._repo[rev1] + ctx2 = self._repo[rev2] + return self._repo.status(ctx1, ctx2, **opts) + except error.RepoLookupError, inst: + self._repo.ui.warn(_('warning: error "%s" in subrepository "%s"\n') + % (inst, subrelpath(self))) + return [], [], [], [], [], [], [] + + def diff(self, diffopts, node2, match, prefix, **opts): + try: + node1 = node.bin(self._state[1]) + # We currently expect node2 to come from substate and be + # in hex format + if node2 is not None: + node2 = node.bin(node2) + cmdutil.diffordiffstat(self._repo.ui, self._repo, diffopts, + node1, node2, match, + prefix=os.path.join(prefix, self._path), + listsubrepos=True, **opts) + except error.RepoLookupError, inst: + self._repo.ui.warn(_('warning: error "%s" in subrepository "%s"\n') + % (inst, subrelpath(self))) + + def archive(self, ui, archiver, prefix, match=None): + self._get(self._state + ('hg',)) + abstractsubrepo.archive(self, ui, archiver, prefix, match) + + rev = self._state[1] + ctx = self._repo[rev] + for subpath in ctx.substate: + s = subrepo(ctx, subpath) + submatch = matchmod.narrowmatcher(subpath, match) + s.archive(ui, archiver, os.path.join(prefix, self._path), submatch) + + def dirty(self, ignoreupdate=False): + r = self._state[1] + if r == '' and not ignoreupdate: # no state recorded + return True + w = self._repo[None] + if r != w.p1().hex() and not ignoreupdate: + # different version checked out + return True + return w.dirty() # working directory changed + + def basestate(self): + return self._repo['.'].hex() + + def checknested(self, path): + return self._repo._checknested(self._repo.wjoin(path)) + + def commit(self, text, user, date): + # don't bother committing in the subrepo if it's only been + # updated + if not self.dirty(True): + return self._repo['.'].hex() + self._repo.ui.debug("committing subrepo %s\n" % subrelpath(self)) + n = self._repo.commit(text, user, date) + if not n: + return self._repo['.'].hex() # different version checked out + return node.hex(n) + + def remove(self): + # we can't fully delete the repository as it may contain + # local-only history + self._repo.ui.note(_('removing subrepo %s\n') % subrelpath(self)) + hg.clean(self._repo, node.nullid, False) + + def _get(self, state): + source, revision, kind = state + if revision not in self._repo: + self._repo._subsource = source + srcurl = _abssource(self._repo) + other = hg.peer(self._repo.ui, {}, srcurl) + if len(self._repo) == 0: + self._repo.ui.status(_('cloning subrepo %s from %s\n') + % (subrelpath(self), srcurl)) + parentrepo = self._repo._subparent + shutil.rmtree(self._repo.path) + other, cloned = hg.clone(self._repo._subparent.ui, {}, + other, self._repo.root, + update=False) + self._repo = cloned.local() + self._initrepo(parentrepo, source, create=True) + else: + self._repo.ui.status(_('pulling subrepo %s from %s\n') + % (subrelpath(self), srcurl)) + self._repo.pull(other) + bookmarks.updatefromremote(self._repo.ui, self._repo, other, + srcurl) + + def get(self, state, overwrite=False): + self._get(state) + source, revision, kind = state + self._repo.ui.debug("getting subrepo %s\n" % self._path) + hg.clean(self._repo, revision, False) + + def merge(self, state): + self._get(state) + cur = self._repo['.'] + dst = self._repo[state[1]] + anc = dst.ancestor(cur) + + def mergefunc(): + if anc == cur and dst.branch() == cur.branch(): + self._repo.ui.debug("updating subrepo %s\n" % subrelpath(self)) + hg.update(self._repo, state[1]) + elif anc == dst: + self._repo.ui.debug("skipping subrepo %s\n" % subrelpath(self)) + else: + self._repo.ui.debug("merging subrepo %s\n" % subrelpath(self)) + hg.merge(self._repo, state[1], remind=False) + + wctx = self._repo[None] + if self.dirty(): + if anc != dst: + if _updateprompt(self._repo.ui, self, wctx.dirty(), cur, dst): + mergefunc() + else: + mergefunc() + else: + mergefunc() + + def push(self, opts): + force = opts.get('force') + newbranch = opts.get('new_branch') + ssh = opts.get('ssh') + + # push subrepos depth-first for coherent ordering + c = self._repo[''] + subs = c.substate # only repos that are committed + for s in sorted(subs): + if c.sub(s).push(opts) == 0: + return False + + dsturl = _abssource(self._repo, True) + self._repo.ui.status(_('pushing subrepo %s to %s\n') % + (subrelpath(self), dsturl)) + other = hg.peer(self._repo.ui, {'ssh': ssh}, dsturl) + return self._repo.push(other, force, newbranch=newbranch) + + def outgoing(self, ui, dest, opts): + return hg.outgoing(ui, self._repo, _abssource(self._repo, True), opts) + + def incoming(self, ui, source, opts): + return hg.incoming(ui, self._repo, _abssource(self._repo, False), opts) + + def files(self): + rev = self._state[1] + ctx = self._repo[rev] + return ctx.manifest() + + def filedata(self, name): + rev = self._state[1] + return self._repo[rev][name].data() + + def fileflags(self, name): + rev = self._state[1] + ctx = self._repo[rev] + return ctx.flags(name) + + def walk(self, match): + ctx = self._repo[None] + return ctx.walk(match) + + def forget(self, ui, match, prefix): + return cmdutil.forget(ui, self._repo, match, + os.path.join(prefix, self._path), True) + + def revert(self, ui, substate, *pats, **opts): + # reverting a subrepo is a 2 step process: + # 1. if the no_backup is not set, revert all modified + # files inside the subrepo + # 2. update the subrepo to the revision specified in + # the corresponding substate dictionary + ui.status(_('reverting subrepo %s\n') % substate[0]) + if not opts.get('no_backup'): + # Revert all files on the subrepo, creating backups + # Note that this will not recursively revert subrepos + # We could do it if there was a set:subrepos() predicate + opts = opts.copy() + opts['date'] = None + opts['rev'] = substate[1] + + pats = [] + if not opts['all']: + pats = ['set:modified()'] + self.filerevert(ui, *pats, **opts) + + # Update the repo to the revision specified in the given substate + self.get(substate, overwrite=True) + + def filerevert(self, ui, *pats, **opts): + ctx = self._repo[opts['rev']] + parents = self._repo.dirstate.parents() + if opts['all']: + pats = ['set:modified()'] + else: + pats = [] + cmdutil.revert(ui, self._repo, ctx, parents, *pats, **opts) + +class svnsubrepo(abstractsubrepo): + def __init__(self, ctx, path, state): + self._path = path + self._state = state + self._ctx = ctx + self._ui = ctx._repo.ui + self._exe = util.findexe('svn') + if not self._exe: + raise util.Abort(_("'svn' executable not found for subrepo '%s'") + % self._path) + + def _svncommand(self, commands, filename='', failok=False): + cmd = [self._exe] + extrakw = {} + if not self._ui.interactive(): + # Making stdin be a pipe should prevent svn from behaving + # interactively even if we can't pass --non-interactive. + extrakw['stdin'] = subprocess.PIPE + # Starting in svn 1.5 --non-interactive is a global flag + # instead of being per-command, but we need to support 1.4 so + # we have to be intelligent about what commands take + # --non-interactive. + if commands[0] in ('update', 'checkout', 'commit'): + cmd.append('--non-interactive') + cmd.extend(commands) + if filename is not None: + path = os.path.join(self._ctx._repo.origroot, self._path, filename) + cmd.append(path) + env = dict(os.environ) + # Avoid localized output, preserve current locale for everything else. + env['LC_MESSAGES'] = 'C' + p = subprocess.Popen(cmd, bufsize=-1, close_fds=util.closefds, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, + universal_newlines=True, env=env, **extrakw) + stdout, stderr = p.communicate() + stderr = stderr.strip() + if not failok: + if p.returncode: + raise util.Abort(stderr or 'exited with code %d' % p.returncode) + if stderr: + self._ui.warn(stderr + '\n') + return stdout, stderr + + @propertycache + def _svnversion(self): + output, err = self._svncommand(['--version'], filename=None) + m = re.search(r'^svn,\s+version\s+(\d+)\.(\d+)', output) + if not m: + raise util.Abort(_('cannot retrieve svn tool version')) + return (int(m.group(1)), int(m.group(2))) + + def _wcrevs(self): + # Get the working directory revision as well as the last + # commit revision so we can compare the subrepo state with + # both. We used to store the working directory one. + output, err = self._svncommand(['info', '--xml']) + doc = xml.dom.minidom.parseString(output) + entries = doc.getElementsByTagName('entry') + lastrev, rev = '0', '0' + if entries: + rev = str(entries[0].getAttribute('revision')) or '0' + commits = entries[0].getElementsByTagName('commit') + if commits: + lastrev = str(commits[0].getAttribute('revision')) or '0' + return (lastrev, rev) + + def _wcrev(self): + return self._wcrevs()[0] + + def _wcchanged(self): + """Return (changes, extchanges, missing) where changes is True + if the working directory was changed, extchanges is + True if any of these changes concern an external entry and missing + is True if any change is a missing entry. + """ + output, err = self._svncommand(['status', '--xml']) + externals, changes, missing = [], [], [] + doc = xml.dom.minidom.parseString(output) + for e in doc.getElementsByTagName('entry'): + s = e.getElementsByTagName('wc-status') + if not s: + continue + item = s[0].getAttribute('item') + props = s[0].getAttribute('props') + path = e.getAttribute('path') + if item == 'external': + externals.append(path) + elif item == 'missing': + missing.append(path) + if (item not in ('', 'normal', 'unversioned', 'external') + or props not in ('', 'none', 'normal')): + changes.append(path) + for path in changes: + for ext in externals: + if path == ext or path.startswith(ext + os.sep): + return True, True, bool(missing) + return bool(changes), False, bool(missing) + + def dirty(self, ignoreupdate=False): + if not self._wcchanged()[0]: + if self._state[1] in self._wcrevs() or ignoreupdate: + return False + return True + + def basestate(self): + lastrev, rev = self._wcrevs() + if lastrev != rev: + # Last committed rev is not the same than rev. We would + # like to take lastrev but we do not know if the subrepo + # URL exists at lastrev. Test it and fallback to rev it + # is not there. + try: + self._svncommand(['list', '%s@%s' % (self._state[0], lastrev)]) + return lastrev + except error.Abort: + pass + return rev + + def commit(self, text, user, date): + # user and date are out of our hands since svn is centralized + changed, extchanged, missing = self._wcchanged() + if not changed: + return self.basestate() + if extchanged: + # Do not try to commit externals + raise util.Abort(_('cannot commit svn externals')) + if missing: + # svn can commit with missing entries but aborting like hg + # seems a better approach. + raise util.Abort(_('cannot commit missing svn entries')) + commitinfo, err = self._svncommand(['commit', '-m', text]) + self._ui.status(commitinfo) + newrev = re.search('Committed revision ([0-9]+).', commitinfo) + if not newrev: + if not commitinfo.strip(): + # Sometimes, our definition of "changed" differs from + # svn one. For instance, svn ignores missing files + # when committing. If there are only missing files, no + # commit is made, no output and no error code. + raise util.Abort(_('failed to commit svn changes')) + raise util.Abort(commitinfo.splitlines()[-1]) + newrev = newrev.groups()[0] + self._ui.status(self._svncommand(['update', '-r', newrev])[0]) + return newrev + + def remove(self): + if self.dirty(): + self._ui.warn(_('not removing repo %s because ' + 'it has changes.\n' % self._path)) + return + self._ui.note(_('removing subrepo %s\n') % self._path) + + def onerror(function, path, excinfo): + if function is not os.remove: + raise + # read-only files cannot be unlinked under Windows + s = os.stat(path) + if (s.st_mode & stat.S_IWRITE) != 0: + raise + os.chmod(path, stat.S_IMODE(s.st_mode) | stat.S_IWRITE) + os.remove(path) + + path = self._ctx._repo.wjoin(self._path) + shutil.rmtree(path, onerror=onerror) + try: + os.removedirs(os.path.dirname(path)) + except OSError: + pass + + def get(self, state, overwrite=False): + if overwrite: + self._svncommand(['revert', '--recursive']) + args = ['checkout'] + if self._svnversion >= (1, 5): + args.append('--force') + # The revision must be specified at the end of the URL to properly + # update to a directory which has since been deleted and recreated. + args.append('%s@%s' % (state[0], state[1])) + status, err = self._svncommand(args, failok=True) + if not re.search('Checked out revision [0-9]+.', status): + if ('is already a working copy for a different URL' in err + and (self._wcchanged()[:2] == (False, False))): + # obstructed but clean working copy, so just blow it away. + self.remove() + self.get(state, overwrite=False) + return + raise util.Abort((status or err).splitlines()[-1]) + self._ui.status(status) + + def merge(self, state): + old = self._state[1] + new = state[1] + wcrev = self._wcrev() + if new != wcrev: + dirty = old == wcrev or self._wcchanged()[0] + if _updateprompt(self._ui, self, dirty, wcrev, new): + self.get(state, False) + + def push(self, opts): + # push is a no-op for SVN + return True + + def files(self): + output = self._svncommand(['list', '--recursive', '--xml'])[0] + doc = xml.dom.minidom.parseString(output) + paths = [] + for e in doc.getElementsByTagName('entry'): + kind = str(e.getAttribute('kind')) + if kind != 'file': + continue + name = ''.join(c.data for c + in e.getElementsByTagName('name')[0].childNodes + if c.nodeType == c.TEXT_NODE) + paths.append(name) + return paths + + def filedata(self, name): + return self._svncommand(['cat'], name)[0] + + +class gitsubrepo(abstractsubrepo): + def __init__(self, ctx, path, state): + self._state = state + self._ctx = ctx + self._path = path + self._relpath = os.path.join(reporelpath(ctx._repo), path) + self._abspath = ctx._repo.wjoin(path) + self._subparent = ctx._repo + self._ui = ctx._repo.ui + self._ensuregit() + + def _ensuregit(self): + try: + self._gitexecutable = 'git' + out, err = self._gitnodir(['--version']) + except OSError, e: + if e.errno != 2 or os.name != 'nt': + raise + self._gitexecutable = 'git.cmd' + out, err = self._gitnodir(['--version']) + m = re.search(r'^git version (\d+)\.(\d+)\.(\d+)', out) + if not m: + self._ui.warn(_('cannot retrieve git version')) + return + version = (int(m.group(1)), m.group(2), m.group(3)) + # git 1.4.0 can't work at all, but 1.5.X can in at least some cases, + # despite the docstring comment. For now, error on 1.4.0, warn on + # 1.5.0 but attempt to continue. + if version < (1, 5, 0): + raise util.Abort(_('git subrepo requires at least 1.6.0 or later')) + elif version < (1, 6, 0): + self._ui.warn(_('git subrepo requires at least 1.6.0 or later')) + + def _gitcommand(self, commands, env=None, stream=False): + return self._gitdir(commands, env=env, stream=stream)[0] + + def _gitdir(self, commands, env=None, stream=False): + return self._gitnodir(commands, env=env, stream=stream, + cwd=self._abspath) + + def _gitnodir(self, commands, env=None, stream=False, cwd=None): + """Calls the git command + + The methods tries to call the git command. versions previor to 1.6.0 + are not supported and very probably fail. + """ + self._ui.debug('%s: git %s\n' % (self._relpath, ' '.join(commands))) + # unless ui.quiet is set, print git's stderr, + # which is mostly progress and useful info + errpipe = None + if self._ui.quiet: + errpipe = open(os.devnull, 'w') + p = subprocess.Popen([self._gitexecutable] + commands, bufsize=-1, + cwd=cwd, env=env, close_fds=util.closefds, + stdout=subprocess.PIPE, stderr=errpipe) + if stream: + return p.stdout, None + + retdata = p.stdout.read().strip() + # wait for the child to exit to avoid race condition. + p.wait() + + if p.returncode != 0 and p.returncode != 1: + # there are certain error codes that are ok + command = commands[0] + if command in ('cat-file', 'symbolic-ref'): + return retdata, p.returncode + # for all others, abort + raise util.Abort('git %s error %d in %s' % + (command, p.returncode, self._relpath)) + + return retdata, p.returncode + + def _gitmissing(self): + return not os.path.exists(os.path.join(self._abspath, '.git')) + + def _gitstate(self): + return self._gitcommand(['rev-parse', 'HEAD']) + + def _gitcurrentbranch(self): + current, err = self._gitdir(['symbolic-ref', 'HEAD', '--quiet']) + if err: + current = None + return current + + def _gitremote(self, remote): + out = self._gitcommand(['remote', 'show', '-n', remote]) + line = out.split('\n')[1] + i = line.index('URL: ') + len('URL: ') + return line[i:] + + def _githavelocally(self, revision): + out, code = self._gitdir(['cat-file', '-e', revision]) + return code == 0 + + def _gitisancestor(self, r1, r2): + base = self._gitcommand(['merge-base', r1, r2]) + return base == r1 + + def _gitisbare(self): + return self._gitcommand(['config', '--bool', 'core.bare']) == 'true' + + def _gitupdatestat(self): + """This must be run before git diff-index. + diff-index only looks at changes to file stat; + this command looks at file contents and updates the stat.""" + self._gitcommand(['update-index', '-q', '--refresh']) + + def _gitbranchmap(self): + '''returns 2 things: + a map from git branch to revision + a map from revision to branches''' + branch2rev = {} + rev2branch = {} + + out = self._gitcommand(['for-each-ref', '--format', + '%(objectname) %(refname)']) + for line in out.split('\n'): + revision, ref = line.split(' ') + if (not ref.startswith('refs/heads/') and + not ref.startswith('refs/remotes/')): + continue + if ref.startswith('refs/remotes/') and ref.endswith('/HEAD'): + continue # ignore remote/HEAD redirects + branch2rev[ref] = revision + rev2branch.setdefault(revision, []).append(ref) + return branch2rev, rev2branch + + def _gittracking(self, branches): + 'return map of remote branch to local tracking branch' + # assumes no more than one local tracking branch for each remote + tracking = {} + for b in branches: + if b.startswith('refs/remotes/'): + continue + bname = b.split('/', 2)[2] + remote = self._gitcommand(['config', 'branch.%s.remote' % bname]) + if remote: + ref = self._gitcommand(['config', 'branch.%s.merge' % bname]) + tracking['refs/remotes/%s/%s' % + (remote, ref.split('/', 2)[2])] = b + return tracking + + def _abssource(self, source): + if '://' not in source: + # recognize the scp syntax as an absolute source + colon = source.find(':') + if colon != -1 and '/' not in source[:colon]: + return source + self._subsource = source + return _abssource(self) + + def _fetch(self, source, revision): + if self._gitmissing(): + source = self._abssource(source) + self._ui.status(_('cloning subrepo %s from %s\n') % + (self._relpath, source)) + self._gitnodir(['clone', source, self._abspath]) + if self._githavelocally(revision): + return + self._ui.status(_('pulling subrepo %s from %s\n') % + (self._relpath, self._gitremote('origin'))) + # try only origin: the originally cloned repo + self._gitcommand(['fetch']) + if not self._githavelocally(revision): + raise util.Abort(_("revision %s does not exist in subrepo %s\n") % + (revision, self._relpath)) + + def dirty(self, ignoreupdate=False): + if self._gitmissing(): + return self._state[1] != '' + if self._gitisbare(): + return True + if not ignoreupdate and self._state[1] != self._gitstate(): + # different version checked out + return True + # check for staged changes or modified files; ignore untracked files + self._gitupdatestat() + out, code = self._gitdir(['diff-index', '--quiet', 'HEAD']) + return code == 1 + + def basestate(self): + return self._gitstate() + + def get(self, state, overwrite=False): + source, revision, kind = state + if not revision: + self.remove() + return + self._fetch(source, revision) + # if the repo was set to be bare, unbare it + if self._gitisbare(): + self._gitcommand(['config', 'core.bare', 'false']) + if self._gitstate() == revision: + self._gitcommand(['reset', '--hard', 'HEAD']) + return + elif self._gitstate() == revision: + if overwrite: + # first reset the index to unmark new files for commit, because + # reset --hard will otherwise throw away files added for commit, + # not just unmark them. + self._gitcommand(['reset', 'HEAD']) + self._gitcommand(['reset', '--hard', 'HEAD']) + return + branch2rev, rev2branch = self._gitbranchmap() + + def checkout(args): + cmd = ['checkout'] + if overwrite: + # first reset the index to unmark new files for commit, because + # the -f option will otherwise throw away files added for + # commit, not just unmark them. + self._gitcommand(['reset', 'HEAD']) + cmd.append('-f') + self._gitcommand(cmd + args) + + def rawcheckout(): + # no branch to checkout, check it out with no branch + self._ui.warn(_('checking out detached HEAD in subrepo %s\n') % + self._relpath) + self._ui.warn(_('check out a git branch if you intend ' + 'to make changes\n')) + checkout(['-q', revision]) + + if revision not in rev2branch: + rawcheckout() + return + branches = rev2branch[revision] + firstlocalbranch = None + for b in branches: + if b == 'refs/heads/master': + # master trumps all other branches + checkout(['refs/heads/master']) + return + if not firstlocalbranch and not b.startswith('refs/remotes/'): + firstlocalbranch = b + if firstlocalbranch: + checkout([firstlocalbranch]) + return + + tracking = self._gittracking(branch2rev.keys()) + # choose a remote branch already tracked if possible + remote = branches[0] + if remote not in tracking: + for b in branches: + if b in tracking: + remote = b + break + + if remote not in tracking: + # create a new local tracking branch + local = remote.split('/', 2)[2] + checkout(['-b', local, remote]) + elif self._gitisancestor(branch2rev[tracking[remote]], remote): + # When updating to a tracked remote branch, + # if the local tracking branch is downstream of it, + # a normal `git pull` would have performed a "fast-forward merge" + # which is equivalent to updating the local branch to the remote. + # Since we are only looking at branching at update, we need to + # detect this situation and perform this action lazily. + if tracking[remote] != self._gitcurrentbranch(): + checkout([tracking[remote]]) + self._gitcommand(['merge', '--ff', remote]) + else: + # a real merge would be required, just checkout the revision + rawcheckout() + + def commit(self, text, user, date): + if self._gitmissing(): + raise util.Abort(_("subrepo %s is missing") % self._relpath) + cmd = ['commit', '-a', '-m', text] + env = os.environ.copy() + if user: + cmd += ['--author', user] + if date: + # git's date parser silently ignores when seconds < 1e9 + # convert to ISO8601 + env['GIT_AUTHOR_DATE'] = util.datestr(date, + '%Y-%m-%dT%H:%M:%S %1%2') + self._gitcommand(cmd, env=env) + # make sure commit works otherwise HEAD might not exist under certain + # circumstances + return self._gitstate() + + def merge(self, state): + source, revision, kind = state + self._fetch(source, revision) + base = self._gitcommand(['merge-base', revision, self._state[1]]) + self._gitupdatestat() + out, code = self._gitdir(['diff-index', '--quiet', 'HEAD']) + + def mergefunc(): + if base == revision: + self.get(state) # fast forward merge + elif base != self._state[1]: + self._gitcommand(['merge', '--no-commit', revision]) + + if self.dirty(): + if self._gitstate() != revision: + dirty = self._gitstate() == self._state[1] or code != 0 + if _updateprompt(self._ui, self, dirty, + self._state[1][:7], revision[:7]): + mergefunc() + else: + mergefunc() + + def push(self, opts): + force = opts.get('force') + + if not self._state[1]: + return True + if self._gitmissing(): + raise util.Abort(_("subrepo %s is missing") % self._relpath) + # if a branch in origin contains the revision, nothing to do + branch2rev, rev2branch = self._gitbranchmap() + if self._state[1] in rev2branch: + for b in rev2branch[self._state[1]]: + if b.startswith('refs/remotes/origin/'): + return True + for b, revision in branch2rev.iteritems(): + if b.startswith('refs/remotes/origin/'): + if self._gitisancestor(self._state[1], revision): + return True + # otherwise, try to push the currently checked out branch + cmd = ['push'] + if force: + cmd.append('--force') + + current = self._gitcurrentbranch() + if current: + # determine if the current branch is even useful + if not self._gitisancestor(self._state[1], current): + self._ui.warn(_('unrelated git branch checked out ' + 'in subrepo %s\n') % self._relpath) + return False + self._ui.status(_('pushing branch %s of subrepo %s\n') % + (current.split('/', 2)[2], self._relpath)) + self._gitcommand(cmd + ['origin', current]) + return True + else: + self._ui.warn(_('no branch checked out in subrepo %s\n' + 'cannot push revision %s\n') % + (self._relpath, self._state[1])) + return False + + def remove(self): + if self._gitmissing(): + return + if self.dirty(): + self._ui.warn(_('not removing repo %s because ' + 'it has changes.\n') % self._relpath) + return + # we can't fully delete the repository as it may contain + # local-only history + self._ui.note(_('removing subrepo %s\n') % self._relpath) + self._gitcommand(['config', 'core.bare', 'true']) + for f in os.listdir(self._abspath): + if f == '.git': + continue + path = os.path.join(self._abspath, f) + if os.path.isdir(path) and not os.path.islink(path): + shutil.rmtree(path) + else: + os.remove(path) + + def archive(self, ui, archiver, prefix, match=None): + source, revision = self._state + if not revision: + return + self._fetch(source, revision) + + # Parse git's native archive command. + # This should be much faster than manually traversing the trees + # and objects with many subprocess calls. + tarstream = self._gitcommand(['archive', revision], stream=True) + tar = tarfile.open(fileobj=tarstream, mode='r|') + relpath = subrelpath(self) + ui.progress(_('archiving (%s)') % relpath, 0, unit=_('files')) + for i, info in enumerate(tar): + if info.isdir(): + continue + if match and not match(info.name): + continue + if info.issym(): + data = info.linkname + else: + data = tar.extractfile(info).read() + archiver.addfile(os.path.join(prefix, self._path, info.name), + info.mode, info.issym(), data) + ui.progress(_('archiving (%s)') % relpath, i + 1, + unit=_('files')) + ui.progress(_('archiving (%s)') % relpath, None) + + + def status(self, rev2, **opts): + rev1 = self._state[1] + if self._gitmissing() or not rev1: + # if the repo is missing, return no results + return [], [], [], [], [], [], [] + modified, added, removed = [], [], [] + self._gitupdatestat() + if rev2: + command = ['diff-tree', rev1, rev2] + else: + command = ['diff-index', rev1] + out = self._gitcommand(command) + for line in out.split('\n'): + tab = line.find('\t') + if tab == -1: + continue + status, f = line[tab - 1], line[tab + 1:] + if status == 'M': + modified.append(f) + elif status == 'A': + added.append(f) + elif status == 'D': + removed.append(f) + + deleted = unknown = ignored = clean = [] + return modified, added, removed, deleted, unknown, ignored, clean + +types = { + 'hg': hgsubrepo, + 'svn': svnsubrepo, + 'git': gitsubrepo, + } |