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