| #!/usr/bin/env python |
| # |
| # svnmerge-migrate-history.py: Migrate merge history from svnmerge.py's |
| # format to Subversion 1.5's format. |
| # |
| # ==================================================================== |
| # Copyright (c) 2007-2009 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$ |
| # $LastChangedDate$ |
| # $LastChangedBy$ |
| # $LastChangedRevision$ |
| |
| import warnings |
| warnings.filterwarnings('ignore', '.*', DeprecationWarning) |
| warnings.filterwarnings('ignore', category=DeprecationWarning) |
| import sys |
| import os |
| import sre |
| import getopt |
| import urllib |
| try: |
| my_getopt = getopt.gnu_getopt |
| except AttributeError: |
| my_getopt = getopt.getopt |
| |
| try: |
| import svn.core |
| import svn.fs |
| import svn.repos |
| except ImportError as e: |
| sys.stderr.write(\ |
| "ERROR: Unable to import Subversion's Python bindings: '%s'\n" \ |
| "Hint: Set your PYTHONPATH environment variable, or adjust your " \ |
| "PYTHONSTARTUP\nfile to point to your Subversion install " \ |
| "location's svn-python directory.\n" % e) |
| sys.exit(1) |
| |
| # Convenience shortcut. |
| mergeinfo2str = svn.core.svn_mergeinfo_to_string |
| |
| # Pretend we have boolean data types for older Python versions. |
| try: |
| True |
| False |
| except: |
| True = 1 |
| False = 0 |
| |
| def usage_and_exit(error_msg=None): |
| """Write usage information and exit. If ERROR_MSG is provide, that |
| error message is printed first (to stderr), the usage info goes to |
| stderr, and the script exits with a non-zero status. Otherwise, |
| usage info goes to stdout and the script exits with a zero status.""" |
| progname = os.path.basename(sys.argv[0]) |
| |
| stream = error_msg and sys.stderr or sys.stdout |
| if error_msg: |
| stream.write("ERROR: %s\n\n" % error_msg) |
| stream.write("""Usage: %s REPOS_PATH [PATH_PREFIX...] [OPTIONS] |
| %s --help |
| |
| Migrate merge history from svnmerge.py's format to Subversion 1.5's |
| format, stopping as soon as merge history is encountered for a |
| directory tree. |
| |
| PATH_PREFIX defines the repository paths to examine for merge history |
| to migrate. If none are listed, the repository's root is examined. |
| |
| Options: |
| |
| --help (-h, -?) Show this usage message. |
| --dry-run Don't actually commit the results of the migration. |
| --naive-mode Perform naive (faster, less accurate) migration. |
| --verbose (-v) Show more informative output. |
| |
| Example: |
| |
| %s /path/to/repos trunk branches tags |
| """ % (progname, progname, progname)) |
| sys.exit(error_msg and 1 or 0) |
| |
| class Migrator: |
| "Migrates merge history." |
| |
| def __init__(self): |
| self.repos_path = None |
| self.path_prefixes = None |
| self.verbose = False |
| self.dry_run = False |
| self.naive_mode = False |
| self.fs = None |
| |
| def log(self, message, only_when_verbose=True): |
| if only_when_verbose and not self.verbose: |
| return |
| print(message) |
| |
| def run(self): |
| self.repos = svn.repos.open(self.repos_path) |
| self.fs = svn.repos.fs(self.repos) |
| |
| revnum = svn.fs.youngest_rev(self.fs) |
| root = svn.fs.revision_root(self.fs, revnum) |
| |
| # Validate path prefixes, retaining path calculations performed in |
| # the process. |
| leading_paths = [] |
| for path_prefix in self.path_prefixes: |
| path = "/".join(path_prefix[:-1]) |
| leading_paths.append(path) |
| if svn.fs.check_path(root, path) != svn.core.svn_node_dir: |
| raise Exception("Repository path '%s' is not a directory" % path) |
| |
| for i in range(0, len(self.path_prefixes)): |
| prefix = self.path_prefixes[i] |
| self.process_dir(root, revnum, leading_paths[i], |
| prefix[len(prefix) - 1] + ".*") |
| |
| def flatten_prop(self, propval): |
| return '\\n'.join(propval.split('\n')) |
| |
| def process_dir(self, root, revnum, dir_path, pattern=None): |
| "Recursively process children of DIR_PATH." |
| dirents = svn.fs.dir_entries(root, dir_path) |
| for name in dirents.keys(): |
| if not dirents[name].kind == svn.core.svn_node_dir: |
| continue |
| if pattern is None or sre.match(pattern, name): |
| if dir_path == "": |
| child_path = name |
| else: |
| child_path = "%s/%s" % (dir_path, name) |
| self.log("Examining path '%s' for conversion" % (child_path)) |
| if not self.convert_path_history(root, revnum, child_path): |
| self.process_dir(root, revnum, child_path) |
| |
| def convert_path_history(self, root, revnum, path): |
| "Migrate the merge history for PATH at ROOT at REVNUM." |
| |
| ### Bother to handle any pre-existing, inherited svn:mergeinfo? |
| |
| # Retrieve existing Subversion 1.5 mergeinfo. |
| mergeinfo_prop_val = svn.fs.node_prop(root, path, |
| svn.core.SVN_PROP_MERGEINFO) |
| if mergeinfo_prop_val is not None: |
| self.log("Discovered pre-existing Subversion mergeinfo of '%s'" \ |
| % (self.flatten_prop(mergeinfo_prop_val))) |
| |
| # Retrieve svnmerge.py's merge history meta data, and roll it into |
| # Subversion 1.5 mergeinfo. |
| integrated_prop_val = svn.fs.node_prop(root, path, "svnmerge-integrated") |
| if integrated_prop_val is not None: |
| self.log("Discovered svnmerge.py mergeinfo of '%s'" \ |
| % (self.flatten_prop(integrated_prop_val))) |
| |
| ### LATER: We handle svnmerge-blocked by converting it into |
| ### svn:mergeinfo, until revision blocking becomes available in |
| ### Subversion's core. |
| blocked_prop_val = svn.fs.node_prop(root, path, "svnmerge-blocked") |
| if blocked_prop_val is not None: |
| self.log("Discovered svnmerge.py blocked revisions of '%s'" \ |
| % (self.flatten_prop(blocked_prop_val))) |
| |
| # Convert property values into real mergeinfo structures. |
| svn_mergeinfo = None |
| if mergeinfo_prop_val is not None: |
| svn_mergeinfo = svn.core.svn_mergeinfo_parse(mergeinfo_prop_val) |
| integrated_mergeinfo = self.svnmerge_prop_to_mergeinfo(integrated_prop_val) |
| blocked_mergeinfo = self.svnmerge_prop_to_mergeinfo(blocked_prop_val) |
| |
| # Add our various bits of stored mergeinfo together. |
| new_mergeinfo = self.mergeinfo_merge(svn_mergeinfo, integrated_mergeinfo) |
| new_mergeinfo = self.mergeinfo_merge(new_mergeinfo, blocked_mergeinfo) |
| |
| if new_mergeinfo is not None: |
| self.log("Combined mergeinfo is '%s'" \ |
| % (self.flatten_prop(mergeinfo2str(new_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_mode and new_mergeinfo: |
| |
| # We begin by subtracting the natural history of the merge |
| # target from its own mergeinfo. |
| rev = svn.fs.revision_root_revision(root) |
| implicit_mergeinfo = self.get_natural_history(path, rev) |
| self.log("Subtracting natural mergeinfo of '%s'" \ |
| % (self.flatten_prop(mergeinfo2str(implicit_mergeinfo)))) |
| new_mergeinfo = svn.core.svn_mergeinfo_remove(implicit_mergeinfo, |
| new_mergeinfo) |
| self.log("Remaining mergeinfo is '%s'" \ |
| % (self.flatten_prop(mergeinfo2str(new_mergeinfo)))) |
| |
| # 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. |
| self.log("Filtering mergeinfo by reconstruction from source history ...") |
| filtered_mergeinfo = {} |
| for source_path, ranges in new_mergeinfo.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: |
| source_history = self.get_natural_history(source_path, |
| range.end, |
| range.start + 1) |
| self.log("... adding '%s'" \ |
| % (self.flatten_prop(mergeinfo2str(source_history)))) |
| filtered_mergeinfo = \ |
| svn.core.svn_mergeinfo_merge(filtered_mergeinfo, |
| source_history) |
| except svn.core.SubversionException as e: |
| if not (e.apr_err == svn.core.SVN_ERR_FS_NOT_FOUND |
| or e.apr_err == svn.core.SVN_ERR_FS_NO_SUCH_REVISION): |
| raise |
| self.log("... done.") |
| new_mergeinfo = filtered_mergeinfo |
| |
| # Turn our to-be-written mergeinfo back into a property value. |
| new_mergeinfo_prop_val = None |
| if new_mergeinfo is not None: |
| new_mergeinfo_prop_val = mergeinfo2str(new_mergeinfo) |
| |
| # If we need to change the value of the svn:mergeinfo property or |
| # delete any svnmerge-* properties, let's do so. |
| if (new_mergeinfo_prop_val != mergeinfo_prop_val) \ |
| or (integrated_prop_val is not None) \ |
| or (blocked_prop_val is not None): |
| |
| # If this not a dry-run, begin a transaction in which we'll |
| # manipulate merge-related properties. Open the transaction root. |
| if not self.dry_run: |
| txn = svn.fs.begin_txn2(self.fs, revnum, 0) |
| root = svn.fs.txn_root(txn) |
| |
| # Manipulate the merge history. |
| if new_mergeinfo_prop_val != mergeinfo_prop_val: |
| # Run the final version of the new svn:mergeinfo through the |
| # parser to ensure it is in canonical form, e.g. no overlapping |
| # or unordered rangelists, see |
| # https://issues.apache.org/jira/browse/SVN-3302. |
| mergeinfo = svn.core.svn_mergeinfo_parse(new_mergeinfo_prop_val) |
| new_mergeinfo_prop_val = mergeinfo2str(mergeinfo) |
| |
| self.log("Queuing change of %s to '%s'" |
| % (svn.core.SVN_PROP_MERGEINFO, |
| self.flatten_prop(new_mergeinfo_prop_val))) |
| if not self.dry_run: |
| svn.fs.change_node_prop(root, path, svn.core.SVN_PROP_MERGEINFO, |
| new_mergeinfo_prop_val) |
| |
| # Remove old property values. |
| if integrated_prop_val is not None: |
| self.log("Queuing removal of svnmerge-integrated") |
| if not self.dry_run: |
| svn.fs.change_node_prop(root, path, "svnmerge-integrated", None) |
| if blocked_prop_val is not None: |
| self.log("Queuing removal of svnmerge-blocked") |
| if not self.dry_run: |
| svn.fs.change_node_prop(root, path, "svnmerge-blocked", None) |
| |
| # Commit the transaction containing our property manipulation. |
| self.log("Committing the transaction containing the above changes") |
| if not self.dry_run: |
| conflict, new_revnum = svn.fs.commit_txn(txn) |
| if conflict: |
| raise Exception("Conflict encountered (%s)" % conflict) |
| self.log("Migrated merge history on '%s' in r%d" |
| % (path, new_revnum), False) |
| else: |
| self.log("Migrated merge history on '%s' in r???" % (path), False) |
| return True |
| else: |
| # No merge history to manipulate. |
| self.log("No merge history on '%s'" % (path)) |
| return False |
| |
| def svnmerge_prop_to_mergeinfo(self, 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 svn.core.svn_mergeinfo_parse(svnmerge_prop_val or '') |
| |
| def mergeinfo_merge(self, 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 svn.core.svn_mergeinfo_merge(mergeinfo1, mergeinfo2) |
| |
| def get_natural_history(self, path, rev, |
| oldest_rev=svn.core.SVN_INVALID_REVNUM): |
| """Return the natural history of PATH in REV, between OLDEST_REV |
| and REV, as mergeinfo. If OLDEST_REV is svn.core.SVN_INVALID_REVNUM, |
| all of PATH's history prior to REV will be returned. |
| (Adapted from Subversion's svn_client__get_history_as_mergeinfo().)""" |
| |
| location_segments = [] |
| def _allow_all(root, path, pool): |
| return 1 |
| def _segment_receiver(segment, pool): |
| location_segments.append(segment) |
| svn.repos.node_location_segments(self.repos, path, rev, rev, oldest_rev, |
| _segment_receiver, _allow_all) |
| |
| # Ensure oldest-to-youngest ordering of revision ranges. |
| location_segments.sort(lambda a, b: cmp(a.range_start, b.range_start)) |
| |
| # Translate 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 = svn.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 set_path_prefixes(self, prefixes): |
| "Decompose path prefixes into something meaningful for comparison." |
| self.path_prefixes = [] |
| for prefix in prefixes: |
| prefix_components = [] |
| parts = prefix.split("/") |
| for i in range(0, len(parts)): |
| prefix_components.append(parts[i]) |
| self.path_prefixes.append(prefix_components) |
| |
| def main(): |
| try: |
| opts, args = my_getopt(sys.argv[1:], "vh?", |
| ["verbose", "dry-run", "naive-mode", "help"]) |
| except: |
| usage_and_exit("Unable to process arguments/options") |
| |
| migrator = Migrator() |
| |
| # Process arguments. |
| if len(args) >= 1: |
| migrator.repos_path = svn.core.svn_path_canonicalize(args[0]) |
| if len(args) >= 2: |
| path_prefixes = args[1:] |
| else: |
| # Default to the root of the repository. |
| path_prefixes = [ "" ] |
| else: |
| usage_and_exit("REPOS_PATH argument required") |
| |
| # Process options. |
| for opt, value in opts: |
| if opt == "--help" or opt in ("-h", "-?"): |
| usage_and_exit() |
| elif opt == "--verbose" or opt == "-v": |
| migrator.verbose = True |
| elif opt == "--dry-run": |
| migrator.dry_run = True |
| elif opt == "--naive-mode": |
| migrator.naive_mode = True |
| else: |
| usage_and_exit("Unknown option '%s'" % opt) |
| |
| migrator.set_path_prefixes(path_prefixes) |
| migrator.run() |
| |
| if __name__ == "__main__": |
| main() |