#!/usr/bin/env python # -*-mode: python; coding: utf-8 -*- # # svn-import - 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 can # operate on a working copy or a repository URL by automatically # checking out a working copy. # # Compared to svn_load_dirs.pl, this utility: # # * Does not hard-code commit messages # * Is much less complicated # * Allows you to fine-tune the import before commit, which # allows you to turn adds+deletes into moves. # # TODO: # Consider not using chdir # Verify symlink support # Perhaps support --username and --password # Perhaps automatically create import dir, if necessary. # Automatic detection of moved files by comparing basenames. import os import re import sys import getopt import atexit import shutil import urlparse import tempfile import subprocess class _VerboseWriter: def __init__(self, verbose=0): self.verbose = verbose def write(self, data): if self.verbose: sys.stderr.write(data) def del_temp_tree(tmpdir): """Delete tree, standring in the root""" os.chdir("/") shutil.rmtree(tmpdir) def copy2_symlinks(src, dst): """Just like shutil.copy2, but copy symbolic links""" if os.path.islink(src): os.symlink(os.readlink(src), dst) else: shutil.copy2(src, dst) def url_join_dir(base, url, using_url): """Join local path or URL""" if using_url: # We must add a trailing slash, to indicate the the URL is a # directory. if not base.endswith("/"): base = base + "/" # Quick compensation for the fact that Python 2.4 and older does # not recoqnize svn:// URLs. base = base.replace("svn://", "http://") result = urlparse.urljoin(base, url) result = result.replace("http://", "svn://") return result else: return os.path.normpath(os.path.join(base, url)) def get_repo_root(path_or_url, using_url): """Get repository root""" ok = None while subprocess.call(["svn", "proplist", path_or_url], stdout=DEVNULL, stderr=subprocess.STDOUT)==0: ok = path_or_url path_or_url = url_join_dir(path_or_url, "..", using_url) if path_or_url == ok: # We have reached the top break return ok def removeprefix(path, prefix): """Remove prefix from path, which makes it possible to turn an absolute path into a relative one. Example: /path/to/libcomplex-1.0/doc, /path/to => libcomplex-1.0/doc """ path = os.path.normpath(path) prefix = os.path.normpath(prefix) if not path.startswith(prefix): raise Exception("%s is not a prefix of %s" % (path, prefix)) path_comps = path.split(os.sep) prefix_comps = prefix.split(os.sep) return os.sep.join(path_comps[len(prefix_comps):]) def get_versioned_files(top): """Get versioned files in directory top""" files = [] svnls = subprocess.Popen(["svn", "ls", top], stdout=subprocess.PIPE) for line in svnls.stdout: # Remove trailing newline line = line[:-1] # Remove trailing slash for directories line = line.replace("/", "") files.append(line) return files def file_is_versioned(file): """Check if file is under version control""" return not subprocess.call(["svn", "ls", file], stdout=DEVNULL, stderr=subprocess.STDOUT) def walk_versioned(top): """Like os.walk, but only for svn versioned files, without onerror support and always topdown""" names = get_versioned_files(top) dirs, nondirs = [], [] for name in names: if os.path.isdir(os.path.join(top, name)): dirs.append(name) else: nondirs.append(name) yield top, dirs, nondirs for name in dirs: path = os.path.join(top, name) if not os.path.islink(path): for x in walk_versioned(path): yield x def delete_removed_files(newtree): """Loop over versioned files in current dir. If files are not found in newtree, do svn delete""" for root, dirs, files in walk_versioned("."): for name in files + dirs: wc_name = os.path.join(root, name) newtree_name = os.path.join(newtree, wc_name) if not os.path.exists(newtree_name): print >>verbosew, "deleting", wc_name subprocess.call(["svn", "delete", wc_name], stdout=DEVNULL) # Prune tree; sufficient to remove the directory if name in dirs: dirs.remove(name) def add_new_files(newtree): """Copy all files from newtree. For files not versioned in working copy, add with svn add. For new directories, do svn mkdir.""" for root, dirs, files in os.walk(newtree): rel_dir = removeprefix(root, newtree) for name in files: wc_name = os.path.join(rel_dir, name) newtree_name = os.path.join(root, name) copy2_symlinks(newtree_name, wc_name) if not file_is_versioned(wc_name): print >>verbosew, "adding", wc_name subprocess.call(["svn", "add", wc_name], stdout=DEVNULL) for name in dirs: wc_name = os.path.join(rel_dir, name) if not file_is_versioned(wc_name): print >>verbosew, "mkdir", wc_name subprocess.call(["svn", "mkdir", wc_name], stdout=DEVNULL) def usage(): """Print usage message and exit""" print >>sys.stderr, """%s: Import a new release, such as a vendor drop. usage: 1. %s [options] NEW_RELEASE PATH 2. %s [options] NEW_RELEASE URL 1. The directory specified by the working copy PATH is adapted to the directory NEW_RELEASE. Example: %s /path/to/libcomplex-1.0 . 2. The repository directory specified by the URL is adapted to the directory NEW_RELEASE. Example: %s /path/to/libcomplex-1.0 http://svn.example.com/repos/vendor/libcomplex/current This command executes these steps: 1. Check out directory specified by URL in a temporary directory (only form 2) 2. Adapt to the directory NEW_RELEASE 3. Allow user to fine-tune import. (only form 2, unless overridden) 4. Commit. (only form 2) 5. Optionally tag new release. 6. Delete the temporary directory (only form 2) Valid options: -h [--help] : show this usage -t [--tag] arg : copy new release to directory ARG, relative to PATH/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 """ % ((os.path.basename(sys.argv[0]),) * 5) sys.exit(1) def main(): tag = None message = None interactive = 1 global verbosew verbosew = _VerboseWriter() try: opts, args = getopt.gnu_getopt(sys.argv[1:], "ht:m:v", ["help", "tag", "message", "non-interactive", "verbose"]) 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"): verbosew.verbose = 1 if len(args) != 2: usage() new_release, path_or_url = args new_release = os.path.abspath(new_release) # Determine form. We cannot use urlparse, since c:\foo is a valid # URL. using_url = re.match("\w+://", path_or_url) is not None if using_url: # Create a temp dir to hold our working copy wc_dir = tempfile.mkdtemp(prefix="svn-import") atexit.register(del_temp_tree, wc_dir) # Check out "current" print >>sys.stderr, "Checking out..." subprocess.call(["svn", "checkout", path_or_url, wc_dir]) else: # We'll need an absolute URL, for various reasons. For # example, "svn copy . ../0.47" gives "Cannot copy path '.' # into its own child " path_or_url = os.path.abspath(path_or_url) wc_dir = path_or_url repo_root = get_repo_root(path_or_url, using_url) if repo_root == None: sys.exit("Error: %s is not a valid URL or working copy PATH" % path_or_url) # Verify tag directory if tag != None: tag_dest = url_join_dir(path_or_url, tag, using_url) if not tag_dest.startswith(repo_root): sys.exit("Error: %s is outside working copy %s" % (tag_dest, repo_root)) os.chdir(wc_dir) # turn into new release print >>sys.stderr, "Adapting to %s..." % new_release delete_removed_files(new_release) add_new_files(new_release) if using_url and interactive: # Give the user a chance to fine-tune print >>sys.stderr, "If you want to fine-tune import, do so in working copy located at:", wc_dir print >>sys.stderr, "When done, press Enter to commit, or Ctrl-C to abort." try: sys.stdin.readline() except KeyboardInterrupt: sys.exit(0) if using_url: # Commit print >>sys.stderr, "Committing..." cmd = ["svn", "commit"] if message is not None: cmd.extend(["-m", message]) subprocess.call(cmd) # If -t was specified, tag this release if tag != None: message = "Tagging %s as %s" % (removeprefix(path_or_url, repo_root), removeprefix(tag_dest, repo_root)) print >>sys.stderr, message subprocess.call(["svn", "copy", "-m", message, path_or_url, tag_dest]) if __name__ == "__main__": DEVNULL = open("/dev/null", "w") main()