#!/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: http://svn.collab.net/repos/svn/branches/1.6.x/contrib/client-side/svn-merge-vendor.py $ # $LastChangedRevision: 27289 $ # $LastChangedDate: 2007-10-19 08:17:06 +0000 (Fri, 19 Oct 2007) $ # $LastChangedBy: aogier $ 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)) # Retreiving 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("Retreiving 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 or abort the program""" for message in messages: print >> sys.stderr, message 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""" print >>sys.stderr, """%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: print >>sys.stder, "", "Current error : "+error 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()