#!/usr/bin/env python3 # Copyright 2015 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. """Rolls DEPS controlled dependency. Works only with git checkout and git dependencies. Currently this script will always roll to the tip of to origin/main. """ import argparse import itertools import os import re import subprocess2 import sys import tempfile import gclient_utils NEED_SHELL = sys.platform.startswith('win') GCLIENT_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'gclient.py') # Commit subject that will be considered a roll. In the format generated by the # git log used, so it's "-- " _ROLL_SUBJECT = re.compile( # Date r'^\d\d\d\d-\d\d-\d\d ' # Author r'[^ ]+ ' # Subject r'(' # Generated by # https://skia.googlesource.com/buildbot/+/HEAdA/autoroll/go/repo_manager/deps_repo_manager.go r'Roll [^ ]+ [a-f0-9]+\.\.[a-f0-9]+ \(\d+ commits\)' r'|' # Generated by # https://chromium.googlesource.com/infra/infra/+/HEAD/recipes/recipe_modules/recipe_autoroller/api.py r'Roll recipe dependencies \(trivial\)\.' r')$') _PUBLIC_GERRIT_HOSTS = { 'android', 'aomedia', 'boringssl', 'chromium', 'dart', 'dawn', 'fuchsia', 'gn', 'go', 'llvm', 'pdfium', 'quiche', 'skia', 'swiftshader', 'webrtc', } class Error(Exception): pass class AlreadyRolledError(Error): pass def check_output(*args, **kwargs): """subprocess2.check_output() passing shell=True on Windows for git.""" kwargs.setdefault('shell', NEED_SHELL) return subprocess2.check_output(*args, **kwargs).decode('utf-8') def check_call(*args, **kwargs): """subprocess2.check_call() passing shell=True on Windows for git.""" kwargs.setdefault('shell', NEED_SHELL) subprocess2.check_call(*args, **kwargs) def return_code(*args, **kwargs): """subprocess2.call() passing shell=True on Windows for git and subprocess2.DEVNULL for stdout and stderr.""" kwargs.setdefault('shell', NEED_SHELL) kwargs.setdefault('stdout', subprocess2.DEVNULL) kwargs.setdefault('stderr', subprocess2.DEVNULL) return subprocess2.call(*args, **kwargs) def is_pristine(root): """Returns True if a git checkout is pristine.""" # `git rev-parse --verify` has a non-zero return code if the revision # doesn't exist. diff_cmd = ['git', 'diff', '--ignore-submodules', 'origin/main'] return (not check_output(diff_cmd, cwd=root).strip() and not check_output(diff_cmd + ['--cached'], cwd=root).strip()) def get_gerrit_host(url): """Returns the host for a given Gitiles URL.""" m = re.match(r'https://([^/]*)\.googlesource\.com/', url) return m and m.group(1) def get_log_url(upstream_url, head, tot): """Returns an URL to read logs via a Web UI if applicable.""" if get_gerrit_host(upstream_url): return '%s/+log/%s..%s' % (upstream_url, head[:12], tot[:12]) if upstream_url.startswith('https://github.com/'): upstream_url = upstream_url.rstrip('/') if upstream_url.endswith('.git'): upstream_url = upstream_url[:-len('.git')] return '%s/compare/%s...%s' % (upstream_url, head[:12], tot[:12]) return None def should_show_log(upstream_url): """Returns True if a short log should be included in the tree.""" # Skip logs for very active projects. if upstream_url.endswith('/v8/v8.git'): return False if 'webrtc' in upstream_url: return False return get_gerrit_host(upstream_url) in _PUBLIC_GERRIT_HOSTS def gclient(args): """Executes gclient with the given args and returns the stdout.""" return check_output([sys.executable, GCLIENT_PATH] + args).strip() def generate_commit_message(full_dir, dependency, head, roll_to, upstream_url, show_log, log_limit): """Creates the commit message for this specific roll.""" commit_range = '%s..%s' % (head, roll_to) commit_range_for_header = '%s..%s' % (head[:9], roll_to[:9]) cmd = ['git', 'log', commit_range, '--date=short', '--no-merges'] logs = check_output( # Args with '=' are automatically quoted. cmd + ['--format=%ad %ae %s', '--'], cwd=full_dir).rstrip() logs = re.sub(r'(?m)^(\d\d\d\d-\d\d-\d\d [^@]+)@[^ ]+( .*)$', r'\1\2', logs) lines = logs.splitlines() cleaned_lines = [l for l in lines if not _ROLL_SUBJECT.match(l)] logs = '\n'.join(cleaned_lines) + '\n' nb_commits = len(lines) rolls = nb_commits - len(cleaned_lines) header = 'Roll %s/ %s (%d commit%s%s)\n\n' % ( dependency, commit_range_for_header, nb_commits, 's' if nb_commits > 1 else '', ('; %s trivial rolls' % rolls) if rolls else '') log_section = '' if log_url := get_log_url(upstream_url, head, roll_to): log_section = log_url + '\n\n' # It is important that --no-log continues to work, as it is used by # internal -> external rollers. Please do not remove or break it. if show_log: log_section += '$ %s ' % ' '.join(cmd) log_section += '--format=\'%ad %ae %s\'\n' log_section = log_section.replace(commit_range, commit_range_for_header) if len(cleaned_lines) > log_limit: # Keep the first N/2 log entries and last N/2 entries. lines = logs.splitlines(True) lines = lines[:log_limit // 2] + ['(...)\n' ] + lines[-log_limit // 2:] logs = ''.join(lines) log_section += logs + '\n' return header + log_section def is_submoduled(): """Returns true if gclient root has submodules""" return os.path.isfile(os.path.join(gclient(['root']), ".gitmodules")) def get_submodule_rev(submodule): """Returns revision of the given submodule path""" rev_output = check_output(['git', 'submodule', 'status', submodule], cwd=gclient(['root'])).strip() # git submodule status returns all submodules with its rev in the # pattern: `(+|-| )() (submodule.path)` revision = rev_output.split(' ')[0] return revision[1:] if revision[0] in ('+', '-') else revision def calculate_roll(full_dir, dependency, roll_to): """Calculates the roll for a dependency by processing gclient_dict, and fetching the dependency via git. """ # if the super-project uses submodules, get rev directly using git. if is_submoduled(): head = get_submodule_rev(dependency) else: head = gclient(['getdep', '-r', dependency]) if not head: raise Error('%s is unpinned.' % dependency) check_call(['git', 'fetch', 'origin', '--quiet'], cwd=full_dir) if roll_to == 'origin/HEAD': check_output(['git', 'remote', 'set-head', 'origin', '-a'], cwd=full_dir) roll_to = check_output(['git', 'rev-parse', roll_to], cwd=full_dir).strip() return head, roll_to def gen_commit_msg(logs, cmdline, reviewers, bug): """Returns the final commit message.""" commit_msg = '' if len(logs) > 1: commit_msg = 'Rolling %d dependencies\n\n' % len(logs) commit_msg += '\n\n'.join(logs) commit_msg += 'Created with:\n ' + cmdline + '\n' commit_msg += 'R=%s\n' % ','.join(reviewers) if reviewers else '' commit_msg += '\nBug: %s\n' % bug if bug else '' return commit_msg def finalize(commit_msg, current_dir, rolls): """Commits changes to the DEPS file, then uploads a CL.""" print('Commit message:') print('\n'.join(' ' + i for i in commit_msg.splitlines())) # Pull the dependency to the right revision. This is surprising to users # otherwise. The revision update is done before commiting to update # submodule revision if present. for dependency, (_head, roll_to, full_dir) in sorted(rolls.items()): check_call(['git', 'checkout', '--quiet', roll_to], cwd=full_dir) # This adds the submodule revision update to the commit. if is_submoduled(): check_call([ 'git', 'update-index', '--add', '--cacheinfo', '160000,{},{}'.format(roll_to, dependency) ], cwd=current_dir) check_call(['git', 'add', 'DEPS'], cwd=current_dir) # We have to set delete=False and then let the object go out of scope so # that the file can be opened by name on Windows. with tempfile.NamedTemporaryFile('w+', newline='', delete=False) as f: commit_filename = f.name f.write(commit_msg) check_call(['git', 'commit', '--quiet', '--file', commit_filename], cwd=current_dir) os.remove(commit_filename) def main(): if gclient_utils.IsEnvCog(): print('"roll-dep" is not supported in non-git environment', file=sys.stderr) return 1 parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--ignore-dirty-tree', action='store_true', help='Roll anyways, even if there is a diff.') parser.add_argument( '-r', '--reviewer', action='append', help='To specify multiple reviewers, either use a comma separated ' 'list, e.g. -r joe,jane,john or provide the flag multiple times, e.g. ' '-r joe -r jane. Defaults to @chromium.org') parser.add_argument('-b', '--bug', help='Associate a bug number to the roll') # It is important that --no-log continues to work, as it is used by # internal -> external rollers. Please do not remove or break it. parser.add_argument( '--no-log', action='store_true', help='Do not include the short log in the commit message') parser.add_argument( '--always-log', action='store_true', help='Always include the short log in the commit message') parser.add_argument('--log-limit', type=int, default=100, help='Trim log after N commits (default: %(default)s)') parser.add_argument( '--roll-to', default='origin/HEAD', help='Specify the new commit to roll to (default: %(default)s)') parser.add_argument('--key', action='append', default=[], help='Regex(es) for dependency in DEPS file') parser.add_argument('dep_path', nargs='+', help='Path(s) to dependency') args = parser.parse_args() if len(args.dep_path) > 1: if args.roll_to != 'origin/HEAD': parser.error( 'Can\'t use multiple paths to roll simultaneously and --roll-to' ) if args.key: parser.error( 'Can\'t use multiple paths to roll simultaneously and --key') if args.no_log and args.always_log: parser.error('Can\'t use both --no-log and --always-log') reviewers = None if args.reviewer: reviewers = list(itertools.chain(*[r.split(',') for r in args.reviewer])) for i, r in enumerate(reviewers): if not '@' in r: reviewers[i] = r + '@chromium.org' gclient_root = gclient(['root']) current_dir = os.getcwd() dependencies = sorted( d.replace('\\', '/').rstrip('/') for d in args.dep_path) cmdline = 'roll-dep ' + ' '.join(dependencies) + ''.join(' --key ' + k for k in args.key) try: if not args.ignore_dirty_tree and not is_pristine(current_dir): raise Error('Ensure %s is clean first (no non-merged commits).' % current_dir) # First gather all the information without modifying anything, except # for a git fetch. rolls = {} for dependency in dependencies: full_dir = os.path.normpath(os.path.join(gclient_root, dependency)) if not os.path.isdir(full_dir): print('Dependency %s not found at %s' % (dependency, full_dir)) full_dir = os.path.normpath( os.path.join(current_dir, dependency)) print('Will look for relative dependency at %s' % full_dir) if not os.path.isdir(full_dir): raise Error('Directory not found: %s (%s)' % (dependency, full_dir)) head, roll_to = calculate_roll(full_dir, dependency, args.roll_to) if roll_to == head: if len(dependencies) == 1: raise AlreadyRolledError('No revision to roll!') print('%s: Already at latest commit %s' % (dependency, roll_to)) else: print('%s: Rolling from %s to %s' % (dependency, head[:10], roll_to[:10])) rolls[dependency] = (head, roll_to, full_dir) logs = [] setdep_args = [] for dependency, (head, roll_to, full_dir) in sorted(rolls.items()): upstream_url = check_output(['git', 'config', 'remote.origin.url'], cwd=full_dir).strip() show_log = args.always_log or \ (not args.no_log and should_show_log(upstream_url)) if not show_log: print( f'{dependency}: Omitting git log from the commit message. ' 'Use the `--always-log` flag to include it.') log = generate_commit_message(full_dir, dependency, head, roll_to, upstream_url, show_log, args.log_limit) logs.append(log) setdep_args.extend(['-r', '{}@{}'.format(dependency, roll_to)]) # DEPS is updated even if the repository uses submodules. gclient(['setdep'] + setdep_args) commit_msg = gen_commit_msg(logs, cmdline, reviewers, args.bug) finalize(commit_msg, current_dir, rolls) except Error as e: sys.stderr.write('error: %s\n' % e) return 2 if isinstance(e, AlreadyRolledError) else 1 except subprocess2.CalledProcessError: return 1 print('') if not reviewers: print('You forgot to pass -r, make sure to insert a R=foo@example.com ' 'line') print('to the commit description before emailing.') print('') print('Run:') print(' git cl upload --send-mail') return 0 if __name__ == '__main__': sys.exit(main())