| #!/usr/bin/env python |
| # -*-mode: python; coding: utf-8 -*- |
| # |
| # Inspired from svn-import.py by astrand@cendio.se (ref : |
| # http://svn.haxx.se/users/archive-2006-10/0857.shtml) |
| # |
| # svn-merge-vendor.py (v1.0.1) - Import a new release, such as a vendor drop. |
| # |
| # The "Vendor branches" chapter of "Version Control with Subversion" |
| # describes how to do a new vendor drop with: |
| # |
| # >The goal here is to make our current directory contain only the |
| # >libcomplex 1.1 code, and to ensure that all that code is under version |
| # >control. Oh, and we want to do this with as little version control |
| # >history disturbance as possible. |
| # |
| # This utility tries to take you to this goal - automatically. Files |
| # new in this release is added to version control, and files removed |
| # in this new release are removed from version control. It will |
| # detect the moved files by looking in the svn log to find the |
| # "copied-from" path ! |
| # |
| # Compared to svn_load_dirs.pl, this utility: |
| # |
| # * DETECTS THE MOVED FILES !! |
| # * Does not hard-code commit messages |
| # * Allows you to fine-tune the import before commit, which |
| # allows you to turn adds+deletes into moves. |
| # |
| # TODO : |
| # * support --username and --password |
| # |
| # This tool is provided under GPL license. Please read |
| # http://www.gnu.org/licenses/gpl.html for the original text. |
| # |
| # $HeadURL$ |
| # $LastChangedRevision$ |
| # $LastChangedDate$ |
| # $LastChangedBy$ |
| |
| import os |
| import re |
| import tempfile |
| import atexit |
| import subprocess |
| import shutil |
| import sys |
| import getopt |
| import logging |
| import string |
| from StringIO import StringIO |
| # lxml module can be found here : http://codespeak.net/lxml/ |
| from lxml import etree |
| import types |
| |
| prog_name = os.path.basename(sys.argv[0]) |
| orig_svn_subroot = None |
| base_copied_paths = [] |
| r_from = None |
| r_to = None |
| log_tree = None |
| entries_to_treat = [] |
| entries_to_delete = [] |
| added_paths = [] |
| logger = None |
| |
| def del_temp_tree(tmpdir): |
| """Delete tree, standring in the root""" |
| global logger |
| logger.info("Deleting tmpdir "+tmpdir) |
| os.chdir("/") |
| try: |
| shutil.rmtree(tmpdir) |
| except OSError: |
| print(logger.warn("Couldn't delete tmpdir %s. Don't forget to remove it manually." % (tmpdir))) |
| |
| |
| def checkout(url, revision=None): |
| """Checks out the given URL at the given revision, using HEAD if not defined. Returns the working copy directory""" |
| global logger |
| # Create a temp dir to hold our working copy |
| wc_dir = tempfile.mkdtemp(prefix=prog_name) |
| atexit.register(del_temp_tree, wc_dir) |
| |
| if (revision): |
| url += "@"+revision |
| |
| # Check out |
| logger.info("Checking out "+url+" to "+wc_dir) |
| returncode = call_cmd(["svn", "checkout", url, wc_dir]) |
| |
| if (returncode == 1): |
| return None |
| else: |
| return wc_dir |
| |
| def merge(wc_dir, revision_from, revision_to): |
| """Merges repo_url from revision revision_from to revision revision_to into wc_dir""" |
| global logger |
| logger.info("Merging between revisions %s and %s into %s" % (revision_from, revision_to, wc_dir)) |
| os.chdir(wc_dir) |
| return call_cmd(["svn", "merge", "-r", revision_from+":"+revision_to, wc_dir]) |
| |
| def treat_status(wc_dir_orig, wc_dir): |
| """Copies modification from official vendor branch to wc""" |
| global logger |
| logger.info("Copying modification from official vendor branch %s to wc %s" % (wc_dir_orig, wc_dir)) |
| os.chdir(wc_dir_orig) |
| status_tree = call_cmd_xml_tree_out(["svn", "status", "--xml"]) |
| global entries_to_treat, entries_to_delete |
| entries_to_treat = status_tree.xpath("/status/target/entry") |
| entries_to_delete = [] |
| |
| while len(entries_to_treat) > 0: |
| entry = entries_to_treat.pop(0) |
| entry_type = get_entry_type(entry) |
| file = get_entry_path(entry) |
| if entry_type == 'added': |
| if is_entry_copied(entry): |
| check_exit(copy(wc_dir_orig, wc_dir, file), "Error during copy") |
| else: |
| check_exit(add(wc_dir_orig, wc_dir, file), "Error during add") |
| elif entry_type == 'deleted': |
| entries_to_delete.append(entry) |
| elif entry_type == 'modified' or entry_type == 'replaced': |
| check_exit(update(wc_dir_orig, wc_dir, file), "Error during update") |
| elif entry_type == 'normal': |
| logger.info("File %s has a 'normal' state (unchanged). Ignoring." % (file)) |
| else: |
| logger.error("Status not understood : '%s' not supported (file : %s)" % (entry_type, file)) |
| |
| # We then treat the left deletions |
| for entry in entries_to_delete: |
| check_exit(delete(wc_dir_orig, wc_dir, get_entry_path(entry)), "Error during delete") |
| |
| return 0 |
| |
| def get_entry_type(entry): |
| return get_xml_text_content(entry, "wc-status/@item") |
| |
| def get_entry_path(entry): |
| return get_xml_text_content(entry, "@path") |
| |
| def is_entry_copied(entry): |
| return get_xml_text_content(entry, "wc-status/@copied") == 'true' |
| |
| def copy(wc_dir_orig, wc_dir, file): |
| global logger |
| logger.info("A+ %s" % (file)) |
| |
| # Retrieving the original URL |
| os.chdir(wc_dir_orig) |
| info_tree = call_cmd_xml_tree_out(["svn", "info", "--xml", os.path.join(wc_dir_orig, file)]) |
| url = get_xml_text_content(info_tree, "/info/entry/url") |
| |
| # Detecting original svn root |
| global orig_svn_subroot |
| if not orig_svn_subroot: |
| orig_svn_root = get_xml_text_content(info_tree, "/info/entry/repository/root") |
| #print >>sys.stderr, "url : %s" % (url) |
| sub_url = url.split(orig_svn_root)[-1] |
| sub_url = os.path.normpath(sub_url) |
| #print >>sys.stderr, "sub_url : %s" % (sub_url) |
| if sub_url.startswith(os.path.sep): |
| sub_url = sub_url[1:] |
| |
| orig_svn_subroot = '/'+sub_url.split(file)[0].replace(os.path.sep, '/') |
| #print >>sys.stderr, "orig_svn_subroot : %s" % (orig_svn_subroot) |
| |
| global log_tree |
| if not log_tree: |
| # Detecting original file copy path |
| os.chdir(wc_dir_orig) |
| orig_svn_root_subroot = get_xml_text_content(info_tree, "/info/entry/repository/root") + orig_svn_subroot |
| real_from = str(int(r_from)+1) |
| logger.info("Retrieving log of the original trunk %s between revisions %s and %s ..." % (orig_svn_root_subroot, real_from, r_to)) |
| log_tree = call_cmd_xml_tree_out(["svn", "log", "--xml", "-v", "-r", "%s:%s" % (real_from, r_to), orig_svn_root_subroot]) |
| |
| # Detecting the path of the original moved or copied file |
| orig_url_file = orig_svn_subroot+file.replace(os.path.sep, '/') |
| orig_url_file_old = None |
| #print >>sys.stderr, " orig_url_file : %s" % (orig_url_file) |
| while orig_url_file: |
| orig_url_file_old = orig_url_file |
| orig_url_file = get_xml_text_content(log_tree, "//path[(@action='R' or @action='A') and text()='%s']/@copyfrom-path" % (orig_url_file)) |
| logger.debug("orig_url_file : %s" % (orig_url_file)) |
| orig_url_file = orig_url_file_old |
| |
| # Getting the relative url for the original url file |
| if orig_url_file: |
| orig_file = convert_relative_url_to_path(orig_url_file) |
| else: |
| orig_file = None |
| global base_copied_paths, added_paths |
| # If there is no "moved origin" for that file, or the origin doesn't exist in the working directory, or the origin is the same as the given file, or the origin is an added file |
| if not orig_url_file or (orig_file and (not os.path.exists(os.path.join(wc_dir, orig_file)) or orig_file == file or orig_file in added_paths)): |
| # Check if the file is within a recently copied path |
| for path in base_copied_paths: |
| if file.startswith(path): |
| logger.warn("The path %s to add is a sub-path of recently copied %s. Ignoring the A+." % (file, path)) |
| return 0 |
| # Simple add the file |
| logger.warn("Log paths for the file %s don't correspond with any file in the wc. Will do a simple A." % (file)) |
| return add(wc_dir_orig, wc_dir, file) |
| |
| # We catch the relative URL for the original file |
| orig_file = convert_relative_url_to_path(orig_url_file) |
| |
| # Detect if it's a move |
| cmd = 'copy' |
| global entries_to_treat, entries_to_delete |
| if search_and_remove_delete_entry(entries_to_treat, orig_file) or search_and_remove_delete_entry(entries_to_delete, orig_file): |
| # It's a move, removing the delete, and treating it as a move |
| cmd = 'move' |
| |
| logger.info("%s from %s" % (cmd, orig_url_file)) |
| returncode = call_cmd(["svn", cmd, os.path.join(wc_dir, orig_file), os.path.join(wc_dir, file)]) |
| if returncode == 0: |
| if os.path.isdir(os.path.join(wc_dir, orig_file)): |
| base_copied_paths.append(file) |
| else: |
| # Copy the last version of the file from the original repository |
| shutil.copy(os.path.join(wc_dir_orig, file), os.path.join(wc_dir, file)) |
| return returncode |
| |
| def search_and_remove_delete_entry(entries, orig_file): |
| for entry in entries: |
| if get_entry_type(entry) == 'deleted' and get_entry_path(entry) == orig_file: |
| entries.remove(entry) |
| return True |
| return False |
| |
| def convert_relative_url_to_path(url): |
| global orig_svn_subroot |
| return os.path.normpath(url.split(orig_svn_subroot)[-1]) |
| |
| def new_added_path(returncode, file): |
| if not is_returncode_bad(returncode): |
| global added_paths |
| added_paths.append(file) |
| |
| def add(wc_dir_orig, wc_dir, file): |
| global logger |
| logger.info("A %s" % (file)) |
| if os.path.exists(os.path.join(wc_dir, file)): |
| logger.warn("Target file %s already exists. Will do a simple M" % (file)) |
| return update(wc_dir_orig, wc_dir, file) |
| os.chdir(wc_dir) |
| if os.path.isdir(os.path.join(wc_dir_orig, file)): |
| returncode = call_cmd(["svn", "mkdir", file]) |
| new_added_path(returncode, file) |
| return returncode |
| else: |
| shutil.copy(os.path.join(wc_dir_orig, file), os.path.join(wc_dir, file)) |
| returncode = call_cmd(["svn", "add", file]) |
| new_added_path(returncode, file) |
| return returncode |
| |
| def delete(wc_dir_orig, wc_dir, file): |
| global logger |
| logger.info("D %s" % (file)) |
| os.chdir(wc_dir) |
| if not os.path.exists(file): |
| logger.warn("File %s doesn't exist. Ignoring D." % (file)) |
| return 0 |
| return call_cmd(["svn", "delete", file]) |
| |
| def update(wc_dir_orig, wc_dir, file): |
| global logger |
| logger.info("M %s" % (file)) |
| if os.path.isdir(os.path.join(wc_dir_orig, file)): |
| logger.warn("%s is a directory. Ignoring M." % (file)) |
| return 0 |
| shutil.copy(os.path.join(wc_dir_orig, file), os.path.join(wc_dir, file)) |
| return 0 |
| |
| def fine_tune(wc_dir): |
| """Gives the user a chance to fine-tune""" |
| alert(["If you want to fine-tune import, do so in working copy located at : %s" % (wc_dir), |
| "When done, press Enter to commit, or Ctrl-C to abort."]) |
| |
| def alert(messages): |
| """Wait the user to <ENTER> or abort the program""" |
| for message in messages: |
| sys.stderr.write(message + "\n") |
| try: |
| return sys.stdin.readline() |
| except KeyboardInterrupt: |
| sys.exit(0) |
| |
| def commit(wc_dir, message): |
| """Commits the wc_dir""" |
| os.chdir(wc_dir) |
| cmd = ["svn", "commit"] |
| if (message): |
| cmd += ["-m", message] |
| return call_cmd(cmd) |
| |
| def tag_wc(repo_url, current, tag, message): |
| """Tags the wc_dir""" |
| cmd = ["svn", "copy"] |
| if (message): |
| cmd += ["-m", message] |
| return call_cmd(cmd + [repo_url+"/"+current, repo_url+"/"+tag]) |
| |
| def call_cmd(cmd): |
| global logger |
| logger.debug(string.join(cmd, ' ')) |
| return subprocess.call(cmd, stdout=DEVNULL, stderr=sys.stderr)#subprocess.STDOUT) |
| |
| def call_cmd_out(cmd): |
| global logger |
| logger.debug(string.join(cmd, ' ')) |
| return subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=sys.stderr).stdout |
| |
| def call_cmd_str_out(cmd): |
| out = call_cmd_out(cmd) |
| str_out = "" |
| for line in out.readlines(): |
| str_out += line |
| out.close() |
| return str_out |
| |
| def call_cmd_xml_tree_out(cmd): |
| return etree.parse(StringIO(call_cmd_str_out(cmd))) |
| |
| def get_xml_text_content(xml_doc, xpath): |
| result_nodes = xml_doc.xpath(xpath) |
| if result_nodes: |
| if type(result_nodes[0]) == types.StringType: |
| return result_nodes[0] |
| else: |
| return result_nodes[0].text |
| else: |
| return None |
| |
| def usage(error = None): |
| """Print usage message and exit""" |
| sys.stderr.write("""%s: Merges the difference between two revisions of the original repository of the vendor, to the vendor branch |
| usage: %s [options] REPO_URL CURRENT_PATH ORIGINAL_REPO_URL -r N:M |
| |
| - REPO_URL : repository URL for the vendor branch (i.e: http://svn.example.com/repos/vendor/libcomplex) |
| - CURRENT_PATH : relative path to the current folder (i.e: current) |
| - ORIGINAL_REPO_URL : original base repository URL |
| - N:M : from revision N to revision M |
| |
| This command executes these steps: |
| |
| 1. Check out directory specified by ORIGINAL_REPO_URL@N in a temporary directory.(1) |
| 2. Merges changes to revision M.(1) |
| 3. Check out directory specified by REPO_URL in a second temporary directory.(2) |
| 4. Treat the merge by "svn status" on the working copy of ORIGINAL_REPO_URL. If the history is kept ('+' when svn st), do a move instead of a delete / add. |
| 5. Allow user to fine-tune import. |
| 6. Commit. |
| 7. Optionally tag new release. |
| 8. Delete the temporary directories. |
| |
| (1) : if -c wasn't passed |
| (2) : if -w wasn't passed |
| |
| Valid options: |
| -r [--revision] N:M : specify revisions N to M |
| -h [--help] : show this usage |
| -t [--tag] arg : copy new release to directory ARG, relative to REPO_URL, |
| using automatic commit message. Example: |
| -t ../0.42 |
| --non-interactive : do no interactive prompting, do not allow manual fine-tune |
| -m [--message] arg : specify commit message ARG |
| -v [--verbose] : verbose mode |
| -c [--merged-vendor] arg : working copy path of the original already merged vendor trunk (skips the steps 1. and 2.) |
| -w [--current-wc] arg : working copy path of the current checked out trunk of the vendor branch (skips the step 3.) |
| """ % ((prog_name,) * 2)) |
| |
| if error: |
| sys.stder.write("\nCurrent error : " + error + "\n") |
| |
| sys.exit(1) |
| |
| def main(): |
| tag = None |
| message = None |
| interactive = 1 |
| revision_to_parse = None |
| merged_vendor = None |
| wc_dir = None |
| |
| # Initializing logger |
| global logger |
| logger = logging.getLogger('svn-merge-vendor') |
| hdlr = logging.StreamHandler(sys.stderr) |
| formatter = logging.Formatter('%(levelname)-8s %(message)s') |
| hdlr.setFormatter(formatter) |
| logger.addHandler(hdlr) |
| logger.setLevel(logging.INFO) |
| |
| try: |
| opts, args = getopt.gnu_getopt(sys.argv[1:], "ht:m:vr:c:w:", |
| ["help", "tag", "message", "non-interactive", "verbose", "revision", "merged-vendor", "current-wc"]) |
| except getopt.GetoptError: |
| # print help information and exit: |
| usage() |
| |
| for o, a in opts: |
| if o in ("-h", "--help"): |
| usage() |
| if o in ("-t", "--tag"): |
| tag = a |
| if o in ("-m", "--message"): |
| message = a |
| if o in ("--non-interactive"): |
| interactive = 0 |
| if o in ("-v", "--verbose"): |
| logger.setLevel(logging.DEBUG) |
| if o in ("-r", "--revision"): |
| revision_to_parse = a |
| if o in ("-c", "--merged-vendor"): |
| merged_vendor = a |
| if o in ("-w", "--current-wc"): |
| wc_dir = a |
| |
| if len(args) != 3: |
| usage() |
| |
| repo_url, current_path, orig_repo_url = args[0:3] |
| |
| if (not revision_to_parse): |
| usage("the revision numbers are mendatory") |
| global r_from, r_to |
| r_from, r_to = re.match("(\d+):(\d+)", revision_to_parse).groups() |
| |
| if not r_from or not r_to: |
| usage("the revision numbers are mendatory") |
| |
| try: |
| r_from_int = int(r_from) |
| r_to_int = int(r_to) |
| except ValueError: |
| usage("the revision parameter is not a number") |
| |
| if r_from_int >= r_to_int: |
| usage("the 'from revision' must be inferior to the 'to revision'") |
| |
| if not merged_vendor: |
| if orig_repo_url.startswith("http://"): |
| wc_dir_orig = checkout(orig_repo_url, r_from) |
| check_exit(wc_dir_orig, "Error during checkout") |
| |
| check_exit(merge(wc_dir_orig, r_from, r_to), "Error during merge") |
| else: |
| usage("ORIGINAL_REPO_URL must start with 'http://'") |
| else: |
| wc_dir_orig = merged_vendor |
| |
| if not wc_dir: |
| wc_dir = checkout(repo_url+"/"+current_path) |
| check_exit(wc_dir, "Error during checkout") |
| |
| check_exit(treat_status(wc_dir_orig, wc_dir), "Error during resolving") |
| |
| if (interactive): |
| fine_tune(wc_dir) |
| |
| if not message: |
| message = "New vendor version, upgrading from revision %s to revision %s" % (r_from, r_to) |
| alert(["No message was specified to commit, the program will use that default one : '%s'" % (message), |
| "Press Enter to commit, or Ctrl-C to abort."]) |
| |
| check_exit(commit(wc_dir, message), "Error during commit") |
| |
| if tag: |
| if not message: |
| message = "Tag %s, when upgrading the vendor branch from revision %s to revision %s" % (tag, r_from, r_to) |
| alert(["No message was specified to tag, the program will use that default one : '%s'" % (message), |
| "Press Enter to tag, or Ctrl-C to abort."]) |
| check_exit(tag_wc(repo_url, current_path, tag, message), "Error during tag") |
| |
| logger.info("Vendor branch merged, passed from %s to %s !" % (r_from, r_to)) |
| |
| def is_returncode_bad(returncode): |
| return returncode is None or returncode == 1 |
| |
| def check_exit(returncode, message): |
| global logger |
| if is_returncode_bad(returncode): |
| logger.error(message) |
| sys.exit(1) |
| |
| if __name__ == "__main__": |
| if (os.name == "nt"): |
| DEVNULL = open("nul:", "w") |
| else: |
| DEVNULL = open("/dev/null", "w") |
| main() |