# dirsync.py - keep two directories synchronized at commit time # # Copyright 2015 Facebook, Inc. # # This software may be used and distributed according to the terms of the # GNU General Public License version 2 or any later version. """ keep directories in a repo synchronized (DEPRECATED) Configure it by adding the following config options to your .hg/hgrc or .hgdirsync in the root of the repo:: [dirsync] projectX.dir1 = path/to/dir1 projectX.dir2 = path/dir2 The configs are of the form "group.name = path-to-dir". Every config entry with the same `group` will be mirrored amongst each other. The `name` is just used to separate them and is not used anywhere. The `path` is the path to the directory from the repo root. It must be a directory, but it doesn't matter if you specify the trailing '/' or not. Multiple mirror groups can be specified at once, and you can mirror between an arbitrary number of directories:: [dirsync] projectX.dir1 = path/to/dir1 projectX.dir2 = path/dir2 projectY.dir1 = otherpath/dir1 projectY.dir2 = foo/bar projectY.dir3 = foo/goo/hoo """ from __future__ import absolute_import import errno from mercurial import ( cmdutil, error, extensions, localrepo, match as matchmod, scmutil, util, ) from mercurial.i18n import _ testedwith = 'ships-with-fb-hgext' _disabled = [False] def extsetup(ui): extensions.wrapfunction(cmdutil, 'amend', _amend) extensions.wrapfunction(localrepo.localrepository, 'commit', _commit) def wrapshelve(loaded=False): try: shelvemod = extensions.find('shelve') extensions.wrapcommand(shelvemod.cmdtable, 'shelve', _bypassdirsync) extensions.wrapcommand(shelvemod.cmdtable, 'unshelve', _bypassdirsync) except KeyError: pass extensions.afterloaded('shelve', wrapshelve) def _bypassdirsync(orig, ui, repo, *args, **kwargs): _disabled[0] = True try: return orig(ui, repo, *args, **kwargs) finally: _disabled[0] = False def getconfigs(repo): # bypass "repoui.copy = baseui.copy # prevent copying repo configuration" ui = repo.ui.__class__.copy(repo.ui) # also read from wvfs/.hgdirsync filename = '.hgdirsync' content = repo.wvfs.tryread(filename) if content: ui._tcfg.parse(filename, '[dirsync]\n%s' % content, ['dirsync']) maps = util.sortdict() for key, value in ui.configitems('dirsync'): if '.' not in key: continue name, disambig = key.split('.', 1) # Normalize paths to have / at the end. For easy concatenation later. if value[-1] != '/': value = value + '/' if name not in maps: maps[name] = [] maps[name].append(value) return maps def getmirrors(maps, filename): for key, mirrordirs in maps.iteritems(): for subdir in mirrordirs: if filename.startswith(subdir): return mirrordirs return [] def _updateworkingcopy(repo, matcher): maps = getconfigs(repo) mirroredfiles = set() if maps: status = repo.status() for added in status.added: mirrors = getmirrors(maps, added) if mirrors and matcher(added): mirroredfiles.update(applytomirrors(repo, status, added, mirrors, 'a')) for modified in status.modified: mirrors = getmirrors(maps, modified) if mirrors and matcher(modified): mirroredfiles.update(applytomirrors(repo, status, modified, mirrors, 'm')) for removed in status.removed: mirrors = getmirrors(maps, removed) if mirrors and matcher(removed): mirroredfiles.update(applytomirrors(repo, status, removed, mirrors, 'r')) return mirroredfiles def _amend(orig, ui, repo, old, extra, pats, opts): # Only wrap if not disabled and repo is instance of # localrepo.localrepository if _disabled[0] or not isinstance(repo, localrepo.localrepository): return orig(ui, repo, old, extra, pats, opts) with repo.wlock(), repo.lock(), repo.transaction('dirsyncamend'): wctx = repo[None] matcher = scmutil.match(wctx, pats, opts) if (opts.get('addremove') and scmutil.addremove(repo, matcher, "", opts)): raise error.Abort( _("failed to mark all new/missing files as added/removed")) mirroredfiles = _updateworkingcopy(repo, matcher) if mirroredfiles and not matcher.always(): # Ensure that all the files to be amended (original + synced) are # under consideration during the amend operation. We do so by # setting the value against 'include' key in opts as the only source # of truth. pats = () opts['include'] = [ f for f in wctx.files() if matcher(f)] + list(mirroredfiles) return orig(ui, repo, old, extra, pats, opts) def _commit(orig, self, *args, **kwargs): if _disabled[0]: return orig(self, *args, **kwargs) with self.wlock(), self.lock(), self.transaction('dirsynccommit'): matcher = args[3] if len(args) >= 4 else kwargs.get('match') matcher = matcher or matchmod.always(self.root, '') mirroredfiles = _updateworkingcopy(self, matcher) if mirroredfiles and not matcher.always(): origmatch = matcher.matchfn def extramatches(path): return path in mirroredfiles or origmatch(path) matcher.matchfn = extramatches matcher._files.extend(mirroredfiles) matcher._fileset.update(mirroredfiles) return orig(self, *args, **kwargs) def applytomirrors(repo, status, sourcepath, mirrors, action): """Applies the changes that are in the sourcepath to all the mirrors.""" mirroredfiles = set() # Detect which mirror this file comes from sourcemirror = None for mirror in mirrors: if sourcepath.startswith(mirror): sourcemirror = mirror break if not sourcemirror: raise error.Abort(_("unable to detect source mirror of '%s'") % (sourcepath,)) relpath = sourcepath[len(sourcemirror):] # Apply the change to each mirror one by one allchanges = set(status.modified + status.removed + status.added) for mirror in mirrors: if mirror == sourcemirror: continue mirrorpath = mirror + relpath mirroredfiles.add(mirrorpath) if mirrorpath in allchanges: wctx = repo[None] if (sourcepath not in wctx and mirrorpath not in wctx and sourcepath in status.removed and mirrorpath in status.removed): if repo.ui.verbose: repo.ui.status(_("not mirroring remove of '%s' to '%s';" " it is already removed\n") % (sourcepath, mirrorpath)) continue if wctx[sourcepath].data() == wctx[mirrorpath].data(): if repo.ui.verbose: repo.ui.status(_("not mirroring '%s' to '%s'; it already " "matches\n") % (sourcepath, mirrorpath)) continue raise error.Abort(_("path '%s' needs to be mirrored to '%s', but " "the target already has pending changes") % (sourcepath, mirrorpath)) fullsource = repo.wjoin(sourcepath) fulltarget = repo.wjoin(mirrorpath) dirstate = repo.dirstate if action == 'm' or action == 'a': mirrorpathdir, unused = util.split(mirrorpath) util.makedirs(repo.wjoin(mirrorpathdir)) util.copyfile(fullsource, fulltarget) if dirstate[mirrorpath] in '?r': dirstate.add(mirrorpath) if action == 'a': # For adds, detect copy data as well copysource = dirstate.copied(sourcepath) if copysource and copysource.startswith(sourcemirror): mirrorcopysource = mirror + copysource[len(sourcemirror):] dirstate.copy(mirrorcopysource, mirrorpath) repo.ui.status(_("mirrored copy '%s -> %s' to '%s -> %s'\n") % (copysource, sourcepath, mirrorcopysource, mirrorpath)) else: repo.ui.status(_("mirrored adding '%s' to '%s'\n") % (sourcepath, mirrorpath)) else: repo.ui.status(_("mirrored changes in '%s' to '%s'\n") % (sourcepath, mirrorpath)) elif action == 'r': try: util.unlink(fulltarget) except OSError as e: if e.errno == errno.ENOENT: repo.ui.status(_("not mirroring remove of '%s' to '%s'; it " "is already removed\n") % (sourcepath, mirrorpath)) else: raise else: dirstate.remove(mirrorpath) repo.ui.status(_("mirrored remove of '%s' to '%s'\n") % (sourcepath, mirrorpath)) return mirroredfiles