1#!/usr/bin/env python3 2 3# 4# Copyright (C) 2018 The Android Open Source Project 5# 6# Licensed under the Apache License, Version 2.0 (the "License"); 7# you may not use this file except in compliance with the License. 8# You may obtain a copy of the License at 9# 10# http://www.apache.org/licenses/LICENSE-2.0 11# 12# Unless required by applicable law or agreed to in writing, software 13# distributed under the License is distributed on an "AS IS" BASIS, 14# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15# See the License for the specific language governing permissions and 16# limitations under the License. 17# 18 19"""A command line utility to pull multiple change lists from Gerrit.""" 20 21from __future__ import print_function 22 23import argparse 24import collections 25import itertools 26import json 27import multiprocessing 28import os 29import re 30import sys 31import xml.dom.minidom 32 33from gerrit import create_url_opener_from_args, query_change_lists 34 35try: 36 # pylint: disable=redefined-builtin 37 from __builtin__ import raw_input as input # PY2 38except ImportError: 39 pass 40 41try: 42 from shlex import quote as _sh_quote # PY3.3 43except ImportError: 44 # Shell language simple string pattern. If a string matches this pattern, 45 # it doesn't have to be quoted. 46 _SHELL_SIMPLE_PATTERN = re.compile('^[a-zA-Z90-9_./-]+$') 47 48 def _sh_quote(txt): 49 """Quote a string if it contains special characters.""" 50 return txt if _SHELL_SIMPLE_PATTERN.match(txt) else json.dumps(txt) 51 52try: 53 from subprocess import PIPE, run # PY3.5 54except ImportError: 55 from subprocess import CalledProcessError, PIPE, Popen 56 57 class CompletedProcess(object): 58 """Process execution result returned by subprocess.run().""" 59 # pylint: disable=too-few-public-methods 60 61 def __init__(self, args, returncode, stdout, stderr): 62 self.args = args 63 self.returncode = returncode 64 self.stdout = stdout 65 self.stderr = stderr 66 67 def run(*args, **kwargs): 68 """Run a command with subprocess.Popen() and redirect input/output.""" 69 70 check = kwargs.pop('check', False) 71 72 try: 73 stdin = kwargs.pop('input') 74 assert 'stdin' not in kwargs 75 kwargs['stdin'] = PIPE 76 except KeyError: 77 stdin = None 78 79 proc = Popen(*args, **kwargs) 80 try: 81 stdout, stderr = proc.communicate(stdin) 82 except: 83 proc.kill() 84 proc.wait() 85 raise 86 returncode = proc.wait() 87 88 if check and returncode: 89 raise CalledProcessError(returncode, args, stdout) 90 return CompletedProcess(args, returncode, stdout, stderr) 91 92 93if bytes is str: 94 def write_bytes(data, file): # PY2 95 """Write bytes to a file.""" 96 # pylint: disable=redefined-builtin 97 file.write(data) 98else: 99 def write_bytes(data, file): # PY3 100 """Write bytes to a file.""" 101 # pylint: disable=redefined-builtin 102 file.buffer.write(data) 103 104 105def _confirm(question, default, file=sys.stderr): 106 """Prompt a yes/no question and convert the answer to a boolean value.""" 107 # pylint: disable=redefined-builtin 108 answers = {'': default, 'y': True, 'yes': True, 'n': False, 'no': False} 109 suffix = '[Y/n] ' if default else ' [y/N] ' 110 while True: 111 file.write(question + suffix) 112 file.flush() 113 ans = answers.get(input().lower()) 114 if ans is not None: 115 return ans 116 117 118class ChangeList(object): 119 """A ChangeList to be checked out.""" 120 # pylint: disable=too-few-public-methods,too-many-instance-attributes 121 122 def __init__(self, project, fetch, commit_sha1, commit, change_list): 123 """Initialize a ChangeList instance.""" 124 # pylint: disable=too-many-arguments 125 126 self.project = project 127 self.number = change_list['_number'] 128 129 self.fetch = fetch 130 131 fetch_git = None 132 for protocol in ('http', 'sso', 'rpc'): 133 fetch_git = fetch.get(protocol) 134 if fetch_git: 135 break 136 137 if not fetch_git: 138 raise ValueError( 139 'unknown fetch protocols: ' + str(list(fetch.keys()))) 140 141 self.fetch_url = fetch_git['url'] 142 self.fetch_ref = fetch_git['ref'] 143 144 self.commit_sha1 = commit_sha1 145 self.commit = commit 146 self.parents = commit['parents'] 147 148 self.change_list = change_list 149 150 151 def is_merge(self): 152 """Check whether this change list a merge commit.""" 153 return len(self.parents) > 1 154 155 156def find_manifest_xml(dir_path): 157 """Find the path to manifest.xml for this Android source tree.""" 158 dir_path_prev = None 159 while dir_path != dir_path_prev: 160 path = os.path.join(dir_path, '.repo', 'manifest.xml') 161 if os.path.exists(path): 162 return path 163 dir_path_prev = dir_path 164 dir_path = os.path.dirname(dir_path) 165 raise ValueError('.repo dir not found') 166 167 168def build_project_name_dir_dict(manifest_path): 169 """Build the mapping from Gerrit project name to source tree project 170 directory path.""" 171 project_dirs = {} 172 parsed_xml = xml.dom.minidom.parse(manifest_path) 173 projects = parsed_xml.getElementsByTagName('project') 174 for project in projects: 175 name = project.getAttribute('name') 176 path = project.getAttribute('path') 177 if path: 178 project_dirs[name] = path 179 else: 180 project_dirs[name] = name 181 return project_dirs 182 183 184def group_and_sort_change_lists(change_lists): 185 """Build a dict that maps projects to a list of topologically sorted change 186 lists.""" 187 188 # Build a dict that map projects to dicts that map commits to changes. 189 projects = collections.defaultdict(dict) 190 for change_list in change_lists: 191 commit_sha1 = None 192 for commit_sha1, value in change_list['revisions'].items(): 193 fetch = value['fetch'] 194 commit = value['commit'] 195 196 if not commit_sha1: 197 raise ValueError('bad revision') 198 199 project = change_list['project'] 200 201 project_changes = projects[project] 202 if commit_sha1 in project_changes: 203 raise KeyError('repeated commit sha1 "{}" in project "{}"'.format( 204 commit_sha1, project)) 205 206 project_changes[commit_sha1] = ChangeList( 207 project, fetch, commit_sha1, commit, change_list) 208 209 # Sort all change lists in a project in post ordering. 210 def _sort_project_change_lists(changes): 211 visited_changes = set() 212 sorted_changes = [] 213 214 def _post_order_traverse(change): 215 visited_changes.add(change) 216 for parent in change.parents: 217 parent_change = changes.get(parent['commit']) 218 if parent_change and parent_change not in visited_changes: 219 _post_order_traverse(parent_change) 220 sorted_changes.append(change) 221 222 for change in sorted(changes.values(), key=lambda x: x.number): 223 if change not in visited_changes: 224 _post_order_traverse(change) 225 226 return sorted_changes 227 228 # Sort changes in each projects 229 sorted_changes = [] 230 for project in sorted(projects.keys()): 231 sorted_changes.append(_sort_project_change_lists(projects[project])) 232 233 return sorted_changes 234 235 236def _main_json(args): 237 """Print the change lists in JSON format.""" 238 change_lists = _get_change_lists_from_args(args) 239 json.dump(change_lists, sys.stdout, indent=4, separators=(', ', ': ')) 240 print() # Print the end-of-line 241 242 243# Git commands for merge commits 244_MERGE_COMMANDS = { 245 'merge': ['git', 'merge', '--no-edit'], 246 'merge-ff-only': ['git', 'merge', '--no-edit', '--ff-only'], 247 'merge-no-ff': ['git', 'merge', '--no-edit', '--no-ff'], 248 'reset': ['git', 'reset', '--hard'], 249 'checkout': ['git', 'checkout'], 250} 251 252 253# Git commands for non-merge commits 254_PICK_COMMANDS = { 255 'pick': ['git', 'cherry-pick', '--allow-empty'], 256 'merge': ['git', 'merge', '--no-edit'], 257 'merge-ff-only': ['git', 'merge', '--no-edit', '--ff-only'], 258 'merge-no-ff': ['git', 'merge', '--no-edit', '--no-ff'], 259 'reset': ['git', 'reset', '--hard'], 260 'checkout': ['git', 'checkout'], 261} 262 263 264def build_pull_commands(change, branch_name, merge_opt, pick_opt): 265 """Build command lines for each change. The command lines will be passed 266 to subprocess.run().""" 267 268 cmds = [] 269 if branch_name is not None: 270 cmds.append(['repo', 'start', branch_name]) 271 cmds.append(['git', 'fetch', change.fetch_url, change.fetch_ref]) 272 if change.is_merge(): 273 cmds.append(_MERGE_COMMANDS[merge_opt] + ['FETCH_HEAD']) 274 else: 275 cmds.append(_PICK_COMMANDS[pick_opt] + ['FETCH_HEAD']) 276 return cmds 277 278 279def _sh_quote_command(cmd): 280 """Convert a command (an argument to subprocess.run()) to a shell command 281 string.""" 282 return ' '.join(_sh_quote(x) for x in cmd) 283 284 285def _sh_quote_commands(cmds): 286 """Convert multiple commands (arguments to subprocess.run()) to shell 287 command strings.""" 288 return ' && '.join(_sh_quote_command(cmd) for cmd in cmds) 289 290 291def _main_bash(args): 292 """Print the bash command to pull the change lists.""" 293 294 branch_name = _get_local_branch_name_from_args(args) 295 296 manifest_path = _get_manifest_xml_from_args(args) 297 project_dirs = build_project_name_dir_dict(manifest_path) 298 299 change_lists = _get_change_lists_from_args(args) 300 change_list_groups = group_and_sort_change_lists(change_lists) 301 302 for changes in change_list_groups: 303 for change in changes: 304 project_dir = project_dirs.get(change.project, change.project) 305 cmds = [] 306 cmds.append(['pushd', project_dir]) 307 cmds.extend(build_pull_commands( 308 change, branch_name, args.merge, args.pick)) 309 cmds.append(['popd']) 310 print(_sh_quote_commands(cmds)) 311 312 313def _do_pull_change_lists_for_project(task): 314 """Pick a list of changes (usually under a project directory).""" 315 changes, task_opts = task 316 317 branch_name = task_opts['branch_name'] 318 merge_opt = task_opts['merge_opt'] 319 pick_opt = task_opts['pick_opt'] 320 project_dirs = task_opts['project_dirs'] 321 322 for i, change in enumerate(changes): 323 try: 324 cwd = project_dirs[change.project] 325 except KeyError: 326 err_msg = 'error: project "{}" cannot be found in manifest.xml\n' 327 err_msg = err_msg.format(change.project).encode('utf-8') 328 return (change, changes[i + 1:], [], err_msg) 329 330 print(change.commit_sha1[0:10], i + 1, cwd) 331 cmds = build_pull_commands(change, branch_name, merge_opt, pick_opt) 332 for cmd in cmds: 333 proc = run(cmd, cwd=cwd, stderr=PIPE) 334 if proc.returncode != 0: 335 return (change, changes[i + 1:], cmd, proc.stderr) 336 return None 337 338 339def _print_pull_failures(failures, file=sys.stderr): 340 """Print pull failures and tracebacks.""" 341 # pylint: disable=redefined-builtin 342 343 separator = '=' * 78 344 separator_sub = '-' * 78 345 346 print(separator, file=file) 347 for failed_change, skipped_changes, cmd, errors in failures: 348 print('PROJECT:', failed_change.project, file=file) 349 print('FAILED COMMIT:', failed_change.commit_sha1, file=file) 350 for change in skipped_changes: 351 print('PENDING COMMIT:', change.commit_sha1, file=file) 352 print(separator_sub, file=sys.stderr) 353 print('FAILED COMMAND:', _sh_quote_command(cmd), file=file) 354 write_bytes(errors, file=sys.stderr) 355 print(separator, file=sys.stderr) 356 357 358def _main_pull(args): 359 """Pull the change lists.""" 360 361 branch_name = _get_local_branch_name_from_args(args) 362 363 manifest_path = _get_manifest_xml_from_args(args) 364 project_dirs = build_project_name_dir_dict(manifest_path) 365 366 # Collect change lists 367 change_lists = _get_change_lists_from_args(args) 368 change_list_groups = group_and_sort_change_lists(change_lists) 369 370 # Build the options list for tasks 371 task_opts = { 372 'branch_name': branch_name, 373 'merge_opt': args.merge, 374 'pick_opt': args.pick, 375 'project_dirs': project_dirs, 376 } 377 378 # Run the commands to pull the change lists 379 if args.parallel <= 1: 380 results = [_do_pull_change_lists_for_project((changes, task_opts)) 381 for changes in change_list_groups] 382 else: 383 pool = multiprocessing.Pool(processes=args.parallel) 384 results = pool.map(_do_pull_change_lists_for_project, 385 zip(change_list_groups, itertools.repeat(task_opts))) 386 387 # Print failures and tracebacks 388 failures = [result for result in results if result] 389 if failures: 390 _print_pull_failures(failures) 391 sys.exit(1) 392 393 394def _parse_args(): 395 """Parse command line options.""" 396 parser = argparse.ArgumentParser() 397 398 parser.add_argument('command', choices=['pull', 'bash', 'json'], 399 help='Commands') 400 401 parser.add_argument('query', help='Change list query string') 402 parser.add_argument('-g', '--gerrit', required=True, 403 help='Gerrit review URL') 404 405 parser.add_argument('--gitcookies', 406 default=os.path.expanduser('~/.gitcookies'), 407 help='Gerrit cookie file') 408 parser.add_argument('--manifest', help='Manifest') 409 parser.add_argument('--limits', default=1000, 410 help='Max number of change lists') 411 412 parser.add_argument('-m', '--merge', 413 choices=sorted(_MERGE_COMMANDS.keys()), 414 default='merge-ff-only', 415 help='Method to pull merge commits') 416 417 parser.add_argument('-p', '--pick', 418 choices=sorted(_PICK_COMMANDS.keys()), 419 default='pick', 420 help='Method to pull merge commits') 421 422 parser.add_argument('-b', '--branch', 423 help='Local branch name for `repo start`') 424 425 parser.add_argument('-j', '--parallel', default=1, type=int, 426 help='Number of parallel running commands') 427 428 return parser.parse_args() 429 430 431def _get_manifest_xml_from_args(args): 432 """Get the path to manifest.xml from args.""" 433 manifest_path = args.manifest 434 if not args.manifest: 435 manifest_path = find_manifest_xml(os.getcwd()) 436 return manifest_path 437 438 439def _get_change_lists_from_args(args): 440 """Query the change lists by args.""" 441 url_opener = create_url_opener_from_args(args) 442 return query_change_lists(url_opener, args.gerrit, args.query, args.limits) 443 444 445def _get_local_branch_name_from_args(args): 446 """Get the local branch name from args.""" 447 if not args.branch and not _confirm( 448 'Do you want to continue without local branch name?', False): 449 print('error: `-b` or `--branch` must be specified', file=sys.stderr) 450 sys.exit(1) 451 return args.branch 452 453 454def main(): 455 """Main function""" 456 args = _parse_args() 457 if args.command == 'json': 458 _main_json(args) 459 elif args.command == 'bash': 460 _main_bash(args) 461 elif args.command == 'pull': 462 _main_pull(args) 463 else: 464 raise KeyError('unknown command') 465 466if __name__ == '__main__': 467 main() 468