#!/usr/bin/env python # Copyright (c) 2012 The Chromium Authors. All rights reserved. # Use of this source code is governed by a BSD-style license that can be # found in the LICENSE file. """ lastchange.py -- Chromium revision fetching utility. """ import re import optparse import os import subprocess import sys _GIT_SVN_ID_REGEX = re.compile(r'.*git-svn-id:\s*([^@]*)@([0-9]+)', re.DOTALL) class VersionInfo(object): def __init__(self, url, revision): self.url = url self.revision = revision def FetchSVNRevision(directory, svn_url_regex): """ Fetch the Subversion branch and revision for a given directory. Errors are swallowed. Returns: A VersionInfo object or None on error. """ try: proc = subprocess.Popen(['svn', 'info'], stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=directory, shell=(sys.platform=='win32')) except OSError: # command is apparently either not installed or not executable. return None if not proc: return None attrs = {} for line in proc.stdout: line = line.strip() if not line: continue key, val = line.split(': ', 1) attrs[key] = val try: match = svn_url_regex.search(attrs['URL']) if match: url = match.group(2) else: url = '' revision = attrs['Revision'] except KeyError: return None return VersionInfo(url, revision) def RunGitCommand(directory, command): """ Launches git subcommand. Errors are swallowed. Returns: A process object or None. """ command = ['git'] + command # Force shell usage under cygwin. This is a workaround for # mysterious loss of cwd while invoking cygwin's git. # We can't just pass shell=True to Popen, as under win32 this will # cause CMD to be used, while we explicitly want a cygwin shell. if sys.platform == 'cygwin': command = ['sh', '-c', ' '.join(command)] try: proc = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=directory, shell=(sys.platform=='win32')) return proc except OSError: return None def FetchGitRevision(directory, hash_only): """ Fetch the Git hash for a given directory. Errors are swallowed. Returns: A VersionInfo object or None on error. """ hsh = '' proc = RunGitCommand(directory, ['rev-parse', 'HEAD']) if proc: output = proc.communicate()[0].strip() if proc.returncode == 0 and output: hsh = output if not hsh: return None pos = '' proc = RunGitCommand(directory, ['cat-file', 'commit', 'HEAD']) if proc: output = proc.communicate()[0] if proc.returncode == 0 and output: for line in reversed(output.splitlines()): if line.startswith('Cr-Commit-Position:'): pos = line.rsplit()[-1].strip() break if hash_only or not pos: return VersionInfo('git', hsh) return VersionInfo('git', '%s-%s' % (hsh, pos)) def FetchGitSVNURLAndRevision(directory, svn_url_regex, go_deeper): """ Fetch the Subversion URL and revision through Git. Errors are swallowed. Returns: A tuple containing the Subversion URL and revision. """ git_args = ['log', '-1', '--format=%b'] if go_deeper: git_args.append('--grep=git-svn-id') proc = RunGitCommand(directory, git_args) if proc: output = proc.communicate()[0].strip() if proc.returncode == 0 and output: # Extract the latest SVN revision and the SVN URL. # The target line is the last "git-svn-id: ..." line like this: # git-svn-id: svn://svn.chromium.org/chrome/trunk/src@85528 0039d316.... match = _GIT_SVN_ID_REGEX.search(output) if match: revision = match.group(2) url_match = svn_url_regex.search(match.group(1)) if url_match: url = url_match.group(2) else: url = '' return url, revision return None, None def FetchGitSVNRevision(directory, svn_url_regex, go_deeper): """ Fetch the Git-SVN identifier for the local tree. Errors are swallowed. """ url, revision = FetchGitSVNURLAndRevision(directory, svn_url_regex, go_deeper) if url and revision: return VersionInfo(url, revision) return None def FetchVersionInfo(default_lastchange, directory=None, directory_regex_prior_to_src_url='chrome|blink|svn', go_deeper=False, hash_only=False): """ Returns the last change (in the form of a branch, revision tuple), from some appropriate revision control system. """ svn_url_regex = re.compile( r'.*/(' + directory_regex_prior_to_src_url + r')(/.*)') version_info = (FetchSVNRevision(directory, svn_url_regex) or FetchGitSVNRevision(directory, svn_url_regex, go_deeper) or FetchGitRevision(directory, hash_only)) if not version_info: if default_lastchange and os.path.exists(default_lastchange): revision = open(default_lastchange, 'r').read().strip() version_info = VersionInfo(None, revision) else: version_info = VersionInfo(None, None) return version_info def GetHeaderGuard(path): """ Returns the header #define guard for the given file path. This treats everything after the last instance of "src/" as being a relevant part of the guard. If there is no "src/", then the entire path is used. """ src_index = path.rfind('src/') if src_index != -1: guard = path[src_index + 4:] else: guard = path guard = guard.upper() return guard.replace('/', '_').replace('.', '_').replace('\\', '_') + '_' def GetHeaderContents(path, define, version): """ Returns what the contents of the header file should be that indicate the given revision. Note that the #define is specified as a string, even though it's currently always a SVN revision number, in case we need to move to git hashes. """ header_guard = GetHeaderGuard(path) header_contents = """/* Generated by lastchange.py, do not edit.*/ #ifndef %(header_guard)s #define %(header_guard)s #define %(define)s "%(version)s" #endif // %(header_guard)s """ header_contents = header_contents % { 'header_guard': header_guard, 'define': define, 'version': version } return header_contents def WriteIfChanged(file_name, contents): """ Writes the specified contents to the specified file_name iff the contents are different than the current contents. """ try: old_contents = open(file_name, 'r').read() except EnvironmentError: pass else: if contents == old_contents: return os.unlink(file_name) open(file_name, 'w').write(contents) def main(argv=None): if argv is None: argv = sys.argv parser = optparse.OptionParser(usage="lastchange.py [options]") parser.add_option("-d", "--default-lastchange", metavar="FILE", help="Default last change input FILE.") parser.add_option("-m", "--version-macro", help="Name of C #define when using --header. Defaults to " + "LAST_CHANGE.", default="LAST_CHANGE") parser.add_option("-o", "--output", metavar="FILE", help="Write last change to FILE. " + "Can be combined with --header to write both files.") parser.add_option("", "--header", metavar="FILE", help="Write last change to FILE as a C/C++ header. " + "Can be combined with --output to write both files.") parser.add_option("--revision-only", action='store_true', help="Just print the SVN revision number. Overrides any " + "file-output-related options.") parser.add_option("-s", "--source-dir", metavar="DIR", help="Use repository in the given directory.") parser.add_option("--git-svn-go-deeper", action='store_true', help="In a Git-SVN repo, dig down to the last committed " + "SVN change (historic behaviour).") parser.add_option("--git-hash-only", action="store_true", help="In a Git repo with commit positions, only report " + "the hash.") opts, args = parser.parse_args(argv[1:]) out_file = opts.output header = opts.header while len(args) and out_file is None: if out_file is None: out_file = args.pop(0) if args: sys.stderr.write('Unexpected arguments: %r\n\n' % args) parser.print_help() sys.exit(2) if opts.source_dir: src_dir = opts.source_dir else: src_dir = os.path.dirname(os.path.abspath(__file__)) version_info = FetchVersionInfo(opts.default_lastchange, directory=src_dir, go_deeper=opts.git_svn_go_deeper, hash_only=opts.git_hash_only) if version_info.revision == None: version_info.revision = '0' if opts.revision_only: print version_info.revision else: contents = "LASTCHANGE=%s\n" % version_info.revision if not out_file and not opts.header: sys.stdout.write(contents) else: if out_file: WriteIfChanged(out_file, contents) if header: WriteIfChanged(header, GetHeaderContents(header, opts.version_macro, version_info.revision)) return 0 if __name__ == '__main__': sys.exit(main())