| #!/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$ |
| # $Date$ |
| # $Author$ |
| # $Rev$ |
| |
| |
| ### 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 as 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() |