#!/usr/bin/env python # # svnmerge-migrate-history-remotely.py: Remotely migrate merge history from # svnmerge.py's format to Subversion 1.5's format. # # ==================================================================== # Copyright (c) 2008 CollabNet. All rights reserved. # # This software is licensed as described in the file COPYING, which # you should have received as part of this distribution. The terms # are also available at http://subversion.tigris.org/license-1.html. # If newer versions of this license are posted there, you may use a # newer version instead, at your option. # # This software consists of voluntary contributions made by many # individuals. For exact contribution history, see the revision # history and logs, available at http://subversion.tigris.org/. # ==================================================================== # $HeadURL: http://svn.apache.org/repos/asf/subversion/branches/1.6.x/contrib/client-side/svnmerge/svnmerge-migrate-history-remotely.py $ # $Date: 2009-01-20 22:25:54 +0000 (Tue, 20 Jan 2009) $ # $Author: cmpilato $ # $Rev: 875432 $ ### THE SVNMERGE HISTORY MIGRATION ALGORITHM EMPLOYED HEREIN ### ### 1. Fetch properties for PATH, looking specifically for ### 'svnmerge-blocked', and 'svnmerge-integrated'. ### ### 2. Convert properties into real mergeinfo. NOTE: svnmerge-* ### properties have a slightly different and more flexible syntax. ### ### 3. Combine mergeinfos together. ### ### 4. [non-naive] Subtract natural history of the merge target from ### its own mergeinfo. ### ### 5. [non-naive] Filter mergeinfo by merge source natural history ### (so that mergeinfo only reflects real locations). ### ### 6. Convert mergeinfo back into a property value. If it reflects ### a change over the previously existing mergeinfo, or there were ### some svnmerge-* properties to delete, then write the new ### mergeinfo and delete the svnmerge-* properties. import sys import os import urllib import getopt try: my_getopt = getopt.gnu_getopt except AttributeError: my_getopt = getopt.getopt try: from svn import client, ra, core svn_version = (core.SVN_VER_MAJOR, core.SVN_VER_MINOR, core.SVN_VER_PATCH) if svn_version < (1, 5, 0): raise ImportError except ImportError: raise Exception, \ "Subversion Python bindings version 1.5.0 or newer required." ### ------------------------------------------------------------------------- def errput(msg, only_a_warning=False): """Output MSG to stderr, possibly decorating it as an error instead of just a warning as determined by ONLY_A_WARNING.""" if not only_a_warning: sys.stderr.write("ERROR: ") sys.stderr.write(msg + "\n") def make_optrev(rev): """Return an svn_opt_revision_t build from revision specifier REV. REV maybe be an integer value or one of the following string specifiers: 'HEAD', 'BASE'.""" try: revnum = int(rev) kind = core.svn_opt_revision_number except ValueError: revnum = -1 if rev == 'HEAD': kind = core.svn_opt_revision_head elif rev == 'BASE': kind = core.svn_opt_revision_base else: raise Exception("Unsupported revision specified '%s'" % str(rev)) optrev = core.svn_opt_revision_t() optrev.kind = kind optrev.value.number = revnum return optrev def svnmerge_prop_to_mergeinfo(svnmerge_prop_val): """Parse svnmerge-* property value SVNMERGE_PROP_VAL (which uses any whitespace for delimiting sources and stores source paths URI-encoded) into Subversion mergeinfo.""" if svnmerge_prop_val is None: return None # First we convert the svnmerge prop value into an svn:mergeinfo # prop value, then we parse it into mergeinfo. sources = svnmerge_prop_val.split() svnmerge_prop_val = '' for source in sources: pieces = source.split(':') if not (len(pieces) == 2 and pieces[1]): continue pieces[0] = urllib.unquote(pieces[0]) svnmerge_prop_val = svnmerge_prop_val + '%s\n' % (':'.join(pieces)) return core.svn_mergeinfo_parse(svnmerge_prop_val or '') def mergeinfo_merge(mergeinfo1, mergeinfo2): """Like svn.core.svn_mergeinfo_merge(), but preserves None-ness.""" if mergeinfo1 is None and mergeinfo2 is None: return None if mergeinfo1 is None: return mergeinfo2 if mergeinfo2 is None: return mergeinfo1 return core.svn_mergeinfo_merge(mergeinfo1, mergeinfo2) def relative_path_from_urls(root_url, url, leading_slash=False): """Return the relative path (not URI-encoded) created by subtracting from URL the ROOT_URL. Add a LEADING_SLASH if requested.""" if root_url == url: return leading_slash and '/' or '' assert url.startswith(root_url) if leading_slash: return urllib.unquote(url[len(root_url):]) else: return urllib.unquote(url[len(root_url)+1:]) def pretty_print_mergeinfo(mergeinfo, indent=0): """Print MERGEINFO hash, one source per line, with a given INDENT.""" mstr = core.svn_mergeinfo_to_string(mergeinfo) sys.stdout.write('\n'.join(map(lambda x: indent * ' ' + x, filter(None, mstr.split('\n')))) + '\n') ### ------------------------------------------------------------------------- class SvnClient: """Subversion client operation abstraction.""" def __init__(self, config_dir=None): core.svn_config_ensure(config_dir) self.ctx = client.ctx_t() self.ctx.auth_baton = core.svn_auth_open([ client.get_simple_provider(), client.get_username_provider(), client.get_ssl_server_trust_file_provider(), client.get_ssl_client_cert_file_provider(), client.get_ssl_client_cert_pw_file_provider(), ]) self.ctx.config = core.svn_config_get_config(config_dir) if config_dir is not None: core.svn_auth_set_parameter(self.ctx.auth_baton, core.SVN_AUTH_PARAM_CONFIG_DIR, config_dir) self.ra_callbacks = ra.callbacks_t() self.ra_callbacks.auth_baton = self.ctx.auth_baton self.base_optrev = make_optrev('BASE') def get_path_urls(self, path): """Return a 2-tuple containing the repository URL associated with the versioned working file or directory located at PATH and the repository URL for the same.""" infos = [] def _info_cb(infopath, info, pool, retval=infos): infos.append(info) client.svn_client_info(path, self.base_optrev, self.base_optrev, _info_cb, 0, self.ctx) assert len(infos) == 1 return infos[0].URL, infos[0].repos_root_URL def get_path_revision(self, path): """Return the current base revision of versioned file or directory PATH.""" infos = [] def _info_cb(infopath, info, pool, retval=infos): infos.append(info) client.svn_client_info(path, self.base_optrev, self.base_optrev, _info_cb, 0, self.ctx) assert len(infos) == 1 return infos[0].rev def get_path_mergeinfo(self, path, root_url=None): mergeinfo = client.mergeinfo_get_merged(path, self.base_optrev, self.ctx) if not mergeinfo: return mergeinfo if not root_url: path_url, root_url = self.get_path_urls(path) if not root_url: ras = self.cc.open_ra_session(url) root_url = ra.get_repos_root(ras) assert root_url new_mergeinfo = {} for key, value in mergeinfo.items(): new_key = relative_path_from_urls(root_url, key, True) new_mergeinfo[new_key] = value return new_mergeinfo def get_path_property(self, path, propname): rev = self.base_optrev prophash, revnum = client.propget3(propname, path, rev, rev, core.svn_depth_empty, None, self.ctx) return prophash.get(path, None) def set_path_property(self, path, propname, propval): client.propset3(propname, propval, path, core.svn_depth_empty, 0, core.SVN_INVALID_REVNUM, None, None, self.ctx) def get_history_as_mergeinfo(self, ra_session, rel_path, rev, oldest_rev=core.SVN_INVALID_REVNUM): """Return the natural history of REL_PATH in REV, between OLDEST_REV and REV, as mergeinfo. If OLDEST_REV is core.SVN_INVALID_REVNUM, all of PATH's history prior to REV will be returned. REL_PATH is relative to the session URL of RA_SESSION. (Adapted from Subversion's svn_client__get_history_as_mergeinfo().)""" # Fetch the location segments in the history. location_segments = [] def _segment_receiver(segment, pool): location_segments.append(segment) ra.get_location_segments(ra_session, rel_path, rev, rev, oldest_rev, _segment_receiver) # Location segments come in youngest to oldest. But we rather # need oldest-to-youngest for proper revision range ordering. location_segments.sort(lambda a, b: cmp(a.range_start, b.range_start)) # Transform location segments into merge sources and ranges. mergeinfo = {} for segment in location_segments: if segment.path is None: continue source_path = '/' + segment.path path_ranges = mergeinfo.get(source_path, []) range = core.svn_merge_range_t() range.start = max(segment.range_start - 1, 0) range.end = segment.range_end range.inheritable = 1 path_ranges.append(range) mergeinfo[source_path] = path_ranges return mergeinfo def open_ra_session(self, session_url): return ra.open(session_url, self.ra_callbacks, None, self.ctx.config) ### ------------------------------------------------------------------------- class SvnmergeHistoryMigrator: """svnmerge.py tracking data conversion class.""" def __init__(self, client_context, verbose=False, naive=False): self.cc = client_context self.verbose = verbose self.naive = naive def migrate_path(self, path): sys.stdout.write("Searching for merge tracking information...\n") # Get svnmerge-integrated property for PATH, as Subversion mergeinfo. integrated_mergeinfo = svnmerge_prop_to_mergeinfo( self.cc.get_path_property(path, 'svnmerge-integrated')) if integrated_mergeinfo and self.verbose: sys.stdout.write("Found svnmerge-integrated:\n") pretty_print_mergeinfo(integrated_mergeinfo, 3) # Get svnmerge-blocked property for PATH, as Subversion mergeinfo. blocked_mergeinfo = svnmerge_prop_to_mergeinfo( self.cc.get_path_property(path, 'svnmerge-blocked')) if blocked_mergeinfo and self.verbose: sys.stdout.write("Found svnmerge-blocked:\n") pretty_print_mergeinfo(blocked_mergeinfo, 3) # No svnmerge tracking data? Nothing to do. if not (integrated_mergeinfo or blocked_mergeinfo): errput("No svnmerge.py tracking data found for '%s'." % (path), True) return # Fetch Subversion mergeinfo for PATH. Hopefully there is # none, but if there is, we'll assume folks want to keep it. orig_mergeinfo = self.cc.get_path_mergeinfo(path) if orig_mergeinfo and self.verbose: sys.stdout.write("Found Subversion mergeinfo:\n") pretty_print_mergeinfo(orig_mergeinfo, 3) # Merge all our mergeinfos together. new_mergeinfo = mergeinfo_merge(orig_mergeinfo, integrated_mergeinfo) new_mergeinfo = mergeinfo_merge(new_mergeinfo, blocked_mergeinfo) # Unless we're doing a naive migration (or we've no, or only # empty, mergeinfo anyway), start trying to cleanup after # svnmerge.py's history-ignorant initialization. if not self.naive and new_mergeinfo: sys.stdout.write("Sanitizing mergeinfo (this can take a " "while)...\n") # What we need: # - the relative path in the repository for PATH # - repository root URL and an RA session rooted thereat # - the base revision of PATH path_url, root_url = self.cc.get_path_urls(path) if root_url: ras = self.cc.open_ra_session(root_url) else: ras = self.cc.open_ra_session(path_url) root_url = ra.get_repos_root(ras) ra.reparent(ras, root_url) assert path_url.startswith(root_url) rel_path = relative_path_from_urls(root_url, path_url) path_rev = self.cc.get_path_revision(path) # We begin by subtracting the natural history of the merge # target from its own mergeinfo. implicit_mergeinfo = \ self.cc.get_history_as_mergeinfo(ras, rel_path, path_rev) if self.verbose: sys.stdout.write(" subtracting natural history:\n") pretty_print_mergeinfo(implicit_mergeinfo, 6) new_mergeinfo = core.svn_mergeinfo_remove(implicit_mergeinfo, new_mergeinfo) if self.verbose: sys.stdout.write(" remaining mergeinfo to be filtered:\n") pretty_print_mergeinfo(new_mergeinfo, 6) # Unfortunately, svnmerge.py tends to initialize using # oft-bogus revision ranges like 1-SOMETHING when the # merge source didn't even exist in r1. So if the natural # history of a branch begins in some revision other than # r1, there's still going to be cruft revisions left in # NEW_MERGEINFO after subtracting the natural history. # So, we also examine the natural history of the merge # sources, and use that as a filter for the explicit # mergeinfo we've calculated so far. mergeinfo_so_far = new_mergeinfo new_mergeinfo = {} for source_path, ranges in mergeinfo_so_far.items(): # If by some chance it is the case that /path:RANGE1 # and /path:RANGE2 a) represent different lines of # history, and b) were combined into # /path:RANGE1+RANGE2 (due to the ranges being # contiguous), we'll foul this up. But the chances # are preeeeeeeetty slim. for range in ranges: try: history = self.cc.get_history_as_mergeinfo( ras, source_path[1:], range.end, range.start + 1) if self.verbose: sys.stdout.write(" new sanitized chunk:\n") pretty_print_mergeinfo(history, 6) new_mergeinfo = mergeinfo_merge(new_mergeinfo, history) except core.SubversionException, e: if not (e.apr_err == core.SVN_ERR_FS_NOT_FOUND or e.apr_err == core.SVN_ERR_FS_NO_SUCH_REVISION): raise if self.verbose: sys.stdout.write("New converted mergeinfo:\n") pretty_print_mergeinfo(new_mergeinfo, 3) sys.stdout.write("Locally removing svnmerge properties and setting " "new svn:mergeinfo property.\n") self.cc.set_path_property(path, 'svnmerge-integrated', None) self.cc.set_path_property(path, 'svnmerge-blocked', None) self.cc.set_path_property(path, 'svn:mergeinfo', core.svn_mergeinfo_to_string(new_mergeinfo)) ### ------------------------------------------------------------------------- def usage_and_exit(errmsg=None): stream = errmsg and sys.stderr or sys.stdout stream.write("""Usage: %s [OPTIONS] BRANCH_PATH Convert svnmerge.py tracking data found on the working copy BRANCH_PATH into Subversion merge tracking data as a set of local property modifications. If BRANCH_PATH already has Subversion merge tracking data, preserve it during the conversion process. After this script runs successfully, you can review and then commit the local property modifications. This script will *not* touch the contents of any files at or under BRANCH_PATH -- it only effects property modifications, which you can revert rather than commit if you so desire. NOTE: BRANCH_PATH need only be a depth-empty checkout of the branch whose svnmerge.py tracking data you wish to convert. NOTE: This script requires remote read access to the Subversion repository whose working copy data you are trying to convert, but currently does not implement prompting authentication providers. You must have valid cached credentials for this script to work. Options: --help (-h, -?) Show this usage message --verbose (-v) Run in verbose mode --naive Run a naive conversion (faster, but might generate non-ideal results) """ % (os.path.basename(sys.argv[0]))) if errmsg: stream.write("\nERROR: %s\n" % (errmsg)) sys.exit(errmsg and 1 or 0) def main(): try: opts, args = my_getopt(sys.argv[1:], "vh?", ["verbose", "naive-mode", "help"]) except: raise usage_and_exit("Unable to process arguments/options.") # Process arguments. if not args: usage_and_exit("No working copy path provided.") else: branch_path = core.svn_path_canonicalize(args[0]) # Process options. verbose = naive_mode = False for opt, value in opts: if opt == "--help" or opt in ("-h", "-?"): usage_and_exit() elif opt == "--verbose" or opt == "-v": verbose = True elif opt == "--naive-mode": naive_mode = True else: usage_and_exit("Unknown option '%s'" % (opt)) # Do the work. shm = SvnmergeHistoryMigrator(SvnClient(), verbose, naive_mode) shm.migrate_path(branch_path) if __name__ == "__main__": main()