Index: change-svn-wc-format.py |
=================================================================== |
--- change-svn-wc-format.py (revision 0) |
+++ change-svn-wc-format.py (revision 0) |
@@ -0,0 +1,414 @@ |
+#!/usr/bin/env python |
+# |
+# change-svn-wc-format.py: Change the format of a Subversion working copy. |
+# |
+# ==================================================================== |
+# 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/. |
+# ==================================================================== |
+ |
+import sys |
+import os |
+import getopt |
+try: |
+ my_getopt = getopt.gnu_getopt |
+except AttributeError: |
+ my_getopt = getopt.getopt |
+ |
+### The entries file parser in subversion/tests/cmdline/svntest/entry.py |
+### handles the XML-based WC entries file format used by Subversion |
+### 1.3 and lower. It could be rolled into this script. |
+ |
+LATEST_FORMATS = { "1.4" : 8, |
+ "1.5" : 9, |
+ "1.6" : 10, |
+ # Do NOT add format 11 here. See comment in must_retain_fields |
+ # for why. |
+ } |
+ |
+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 WC_PATH SVN_VERSION [--verbose] [--force] [--skip-unknown-format] |
+ %s --help |
+ |
+Change the format of a Subversion working copy to that of SVN_VERSION. |
+ |
+ --skip-unknown-format : skip directories with unknown working copy |
+ format and continue the update |
+ |
+""" % (progname, progname)) |
+ stream.flush() |
+ sys.exit(error_msg and 1 or 0) |
+ |
+def get_adm_dir(): |
+ """Return the name of Subversion's administrative directory, |
+ adjusted for the SVN_ASP_DOT_NET_HACK environment variable. See |
+ <http://svn.collab.net/repos/svn/trunk/notes/asp-dot-net-hack.txt> |
+ for details.""" |
+ return "SVN_ASP_DOT_NET_HACK" in os.environ and "_svn" or ".svn" |
+ |
+class WCFormatConverter: |
+ "Performs WC format conversions." |
+ root_path = None |
+ error_on_unrecognized = True |
+ force = False |
+ verbosity = 0 |
+ |
+ def write_dir_format(self, format_nbr, dirname, paths): |
+ """Attempt to write the WC format FORMAT_NBR to the entries file |
+ for DIRNAME. Throws LossyConversionException when not in --force |
+ mode, and unconvertable WC data is encountered.""" |
+ |
+ # Avoid iterating in unversioned directories. |
+ if not (get_adm_dir() in paths): |
+ del paths[:] |
+ return |
+ |
+ # Process the entries file for this versioned directory. |
+ if self.verbosity: |
+ print("Processing directory '%s'" % dirname) |
+ entries = Entries(os.path.join(dirname, get_adm_dir(), "entries")) |
+ entries_parsed = True |
+ if self.verbosity: |
+ print("Parsing file '%s'" % entries.path) |
+ try: |
+ entries.parse(self.verbosity) |
+ except UnrecognizedWCFormatException, e: |
+ if self.error_on_unrecognized: |
+ raise |
+ sys.stderr.write("%s, skipping\n" % e) |
+ sys.stderr.flush() |
+ entries_parsed = False |
+ |
+ if entries_parsed: |
+ format = Format(os.path.join(dirname, get_adm_dir(), "format")) |
+ if self.verbosity: |
+ print("Updating file '%s'" % format.path) |
+ format.write_format(format_nbr, self.verbosity) |
+ else: |
+ if self.verbosity: |
+ print("Skipping file '%s'" % format.path) |
+ |
+ if self.verbosity: |
+ print("Checking whether WC format can be converted") |
+ try: |
+ entries.assert_valid_format(format_nbr, self.verbosity) |
+ except LossyConversionException, e: |
+ # In --force mode, ignore complaints about lossy conversion. |
+ if self.force: |
+ print("WARNING: WC format conversion will be lossy. Dropping "\ |
+ "field(s) %s " % ", ".join(e.lossy_fields)) |
+ else: |
+ raise |
+ |
+ if self.verbosity: |
+ print("Writing WC format") |
+ entries.write_format(format_nbr) |
+ |
+ def change_wc_format(self, format_nbr): |
+ """Walk all paths in a WC tree, and change their format to |
+ FORMAT_NBR. Throw LossyConversionException or NotImplementedError |
+ if the WC format should not be converted, or is unrecognized.""" |
+ for dirpath, dirs, files in os.walk(self.root_path): |
+ self.write_dir_format(format_nbr, dirpath, dirs + files) |
+ |
+class Entries: |
+ """Represents a .svn/entries file. |
+ |
+ 'The entries file' section in subversion/libsvn_wc/README is a |
+ useful reference.""" |
+ |
+ # The name and index of each field composing an entry's record. |
+ entry_fields = ( |
+ "name", |
+ "kind", |
+ "revision", |
+ "url", |
+ "repos", |
+ "schedule", |
+ "text-time", |
+ "checksum", |
+ "committed-date", |
+ "committed-rev", |
+ "last-author", |
+ "has-props", |
+ "has-prop-mods", |
+ "cachable-props", |
+ "present-props", |
+ "conflict-old", |
+ "conflict-new", |
+ "conflict-wrk", |
+ "prop-reject-file", |
+ "copied", |
+ "copyfrom-url", |
+ "copyfrom-rev", |
+ "deleted", |
+ "absent", |
+ "incomplete", |
+ "uuid", |
+ "lock-token", |
+ "lock-owner", |
+ "lock-comment", |
+ "lock-creation-date", |
+ "changelist", |
+ "keep-local", |
+ "working-size", |
+ "depth", |
+ "tree-conflicts", |
+ "file-external", |
+ ) |
+ |
+ # The format number. |
+ format_nbr = -1 |
+ |
+ # How many bytes the format number takes in the file. (The format number |
+ # may have leading zeroes after using this script to convert format 10 to |
+ # format 9 -- which would write the format number as '09'.) |
+ format_nbr_bytes = -1 |
+ |
+ def __init__(self, path): |
+ self.path = path |
+ self.entries = [] |
+ |
+ def parse(self, verbosity=0): |
+ """Parse the entries file. Throw NotImplementedError if the WC |
+ format is unrecognized.""" |
+ |
+ input = open(self.path, "r") |
+ |
+ # Read WC format number from INPUT. Validate that it |
+ # is a supported format for conversion. |
+ format_line = input.readline() |
+ try: |
+ self.format_nbr = int(format_line) |
+ self.format_nbr_bytes = len(format_line.rstrip()) # remove '\n' |
+ except ValueError: |
+ self.format_nbr = -1 |
+ self.format_nbr_bytes = -1 |
+ if not self.format_nbr in LATEST_FORMATS.values(): |
+ raise UnrecognizedWCFormatException(self.format_nbr, self.path) |
+ |
+ # Parse file into individual entries, to later inspect for |
+ # non-convertable data. |
+ entry = None |
+ while True: |
+ entry = self.parse_entry(input, verbosity) |
+ if entry is None: |
+ break |
+ self.entries.append(entry) |
+ |
+ input.close() |
+ |
+ def assert_valid_format(self, format_nbr, verbosity=0): |
+ if verbosity >= 2: |
+ print("Validating format for entries file '%s'" % self.path) |
+ for entry in self.entries: |
+ if verbosity >= 3: |
+ print("Validating format for entry '%s'" % entry.get_name()) |
+ try: |
+ entry.assert_valid_format(format_nbr) |
+ except LossyConversionException: |
+ if verbosity >= 3: |
+ sys.stderr.write("Offending entry:\n%s\n" % entry) |
+ sys.stderr.flush() |
+ raise |
+ |
+ def parse_entry(self, input, verbosity=0): |
+ "Read an individual entry from INPUT stream." |
+ entry = None |
+ |
+ while True: |
+ line = input.readline() |
+ if line in ("", "\x0c\n"): |
+ # EOF or end of entry terminator encountered. |
+ break |
+ |
+ if entry is None: |
+ entry = Entry() |
+ |
+ # Retain the field value, ditching its field terminator ("\x0a"). |
+ entry.fields.append(line[:-1]) |
+ |
+ if entry is not None and verbosity >= 3: |
+ sys.stdout.write(str(entry)) |
+ print("-" * 76) |
+ return entry |
+ |
+ def write_format(self, format_nbr): |
+ # Overwrite all bytes of the format number (which are the first bytes in |
+ # the file). Overwrite format '10' by format '09', which will be converted |
+ # to '9' by Subversion when it rewrites the file. (Subversion 1.4 and later |
+ # ignore leading zeroes in the format number.) |
+ assert len(str(format_nbr)) <= self.format_nbr_bytes |
+ format_string = '%0' + str(self.format_nbr_bytes) + 'd' |
+ |
+ os.chmod(self.path, 0600) |
+ output = open(self.path, "r+", 0) |
+ output.write(format_string % format_nbr) |
+ output.close() |
+ os.chmod(self.path, 0400) |
+ |
+class Entry: |
+ "Describes an entry in a WC." |
+ |
+ # Maps format numbers to indices of fields within an entry's record that must |
+ # be retained when downgrading to that format. |
+ must_retain_fields = { |
+ # Not in 1.4: changelist, keep-local, depth, tree-conflicts, file-externals |
+ 8 : (30, 31, 33, 34, 35), |
+ # Not in 1.5: tree-conflicts, file-externals |
+ 9 : (34, 35), |
+ 10 : (), |
+ # Downgrading from format 11 (1.7-dev) to format 10 is not possible, |
+ # because 11 does not use has-props and cachable-props (but 10 does). |
+ # Naively downgrading in that situation causes properties to disappear |
+ # from the wc. |
+ # |
+ # Downgrading from the 1.7 SQLite-based format to format 10 is not |
+ # implemented. |
+ } |
+ |
+ def __init__(self): |
+ self.fields = [] |
+ |
+ def assert_valid_format(self, format_nbr): |
+ "Assure that conversion will be non-lossy by examining fields." |
+ |
+ # Check whether lossy conversion is being attempted. |
+ lossy_fields = [] |
+ for field_index in self.must_retain_fields[format_nbr]: |
+ if len(self.fields) - 1 >= field_index and self.fields[field_index]: |
+ lossy_fields.append(Entries.entry_fields[field_index]) |
+ if lossy_fields: |
+ raise LossyConversionException(lossy_fields, |
+ "Lossy WC format conversion requested for entry '%s'\n" |
+ "Data for the following field(s) is unsupported by older versions " |
+ "of\nSubversion, and is likely to be subsequently discarded, and/or " |
+ "have\nunexpected side-effects: %s\n\n" |
+ "WC format conversion was cancelled, use the --force option to " |
+ "override\nthe default behavior." |
+ % (self.get_name(), ", ".join(lossy_fields))) |
+ |
+ def get_name(self): |
+ "Return the name of this entry." |
+ return len(self.fields) > 0 and self.fields[0] or "" |
+ |
+ def __str__(self): |
+ "Return all fields from this entry as a multi-line string." |
+ rep = "" |
+ for i in range(0, len(self.fields)): |
+ rep += "[%s] %s\n" % (Entries.entry_fields[i], self.fields[i]) |
+ return rep |
+ |
+class Format: |
+ """Represents a .svn/format file.""" |
+ |
+ def __init__(self, path): |
+ self.path = path |
+ |
+ def write_format(self, format_nbr, verbosity=0): |
+ format_string = '%d\n' |
+ if os.path.exists(self.path): |
+ if verbosity >= 1: |
+ print("%s will be updated." % self.path) |
+ os.chmod(self.path,0600) |
+ else: |
+ if verbosity >= 1: |
+ print("%s does not exist, creating it." % self.path) |
+ format = open(self.path, "w") |
+ format.write(format_string % format_nbr) |
+ format.close() |
+ os.chmod(self.path, 0400) |
+ |
+class LocalException(Exception): |
+ """Root of local exception class hierarchy.""" |
+ pass |
+ |
+class LossyConversionException(LocalException): |
+ "Exception thrown when a lossy WC format conversion is requested." |
+ def __init__(self, lossy_fields, str): |
+ self.lossy_fields = lossy_fields |
+ self.str = str |
+ def __str__(self): |
+ return self.str |
+ |
+class UnrecognizedWCFormatException(LocalException): |
+ def __init__(self, format, path): |
+ self.format = format |
+ self.path = path |
+ def __str__(self): |
+ return ("Unrecognized WC format %d in '%s'; " |
+ "only formats 8, 9, and 10 can be supported") % (self.format, self.path) |
+ |
+ |
+def main(): |
+ try: |
+ opts, args = my_getopt(sys.argv[1:], "vh?", |
+ ["debug", "force", "skip-unknown-format", |
+ "verbose", "help"]) |
+ except: |
+ usage_and_exit("Unable to process arguments/options") |
+ |
+ converter = WCFormatConverter() |
+ |
+ # Process arguments. |
+ if len(args) == 2: |
+ converter.root_path = args[0] |
+ svn_version = args[1] |
+ else: |
+ usage_and_exit() |
+ |
+ # Process options. |
+ debug = False |
+ for opt, value in opts: |
+ if opt in ("--help", "-h", "-?"): |
+ usage_and_exit() |
+ elif opt == "--force": |
+ converter.force = True |
+ elif opt == "--skip-unknown-format": |
+ converter.error_on_unrecognized = False |
+ elif opt in ("--verbose", "-v"): |
+ converter.verbosity += 1 |
+ elif opt == "--debug": |
+ debug = True |
+ else: |
+ usage_and_exit("Unknown option '%s'" % opt) |
+ |
+ try: |
+ new_format_nbr = LATEST_FORMATS[svn_version] |
+ except KeyError: |
+ usage_and_exit("Unsupported version number '%s'; " |
+ "only 1.4, 1.5, and 1.6 can be supported" % svn_version) |
+ |
+ try: |
+ converter.change_wc_format(new_format_nbr) |
+ except LocalException, e: |
+ if debug: |
+ raise |
+ sys.stderr.write("%s\n" % e) |
+ sys.stderr.flush() |
+ sys.exit(1) |
+ |
+ print("Converted WC at '%s' into format %d for Subversion %s" % \ |
+ (converter.root_path, new_format_nbr, svn_version)) |
+ |
+if __name__ == "__main__": |
+ main() |