#!/usr/bin/python # -*- coding: utf-8 -*- # Copyright (C) 2015 The Qt Company Ltd. # Contact: http://www.qt.io/licensing/ # # You may use this file under the terms of the 3-clause BSD license. # See the file LICENSE from this package for details. # from __future__ import print_function import os import sys import logging import subprocess import re import warnings fnull = open(os.devnull, "w") class colors: HEADER = '\033[95m' BLUE = '\033[94m' GREEN = '\033[92m' WARNING = '\033[93m' FAIL = '\033[91m' ENDC = '\033[0m' class BlameParser: __begin = ('<' * 7, '=' * 7) __end_map = {'merge': '=', 'diff3': '|'} # One example line to parse (result from git blame -c --date=short): # "ddd8ab31 (Andras Becsi 2014-06-13 18:17:07 +0200 414)bool GLSurface::InitializeOneOffInternal()" __matcher = re.compile(r"([0-9a-f]+)\s+\((.*?)\s+\d+\)(.*)").match def __init__(self): self.__end = (self.__from_git_config('merge.conflictstyle', 'merge', self.__end_map) * 7, '>' * 7) def __from_git_config(self, parameter, default, mapping): try: value = subprocess.check_output(['git', 'config', parameter]).decode('utf-8').strip() except subprocess.CalledProcessError: # When the value is unset value = '' try: return mapping[value or default] except KeyError: raise ValueError('Unknown git config', parameter, value) def __parse_blame_line(self, codeline): # Match a "git blame -c --date=short" output line m = self.__matcher(codeline) if m: return m.group(1, 2, 3) raise ValueError("Failed to parse blame line: %s" % (codeline)) def __get_blame_results(self, conflict): cmd_git_blame = ["git", "blame", "-c", "--date=short", conflict] blame_output = subprocess.check_output(cmd_git_blame).decode('utf-8') return blame_output.split('\n') def parse(self, conflict): results, scan = {}, False for line in self.__get_blame_results(conflict): if not line.strip(): continue sha1, info, code = self.__parse_blame_line(line) if any(code.startswith(b) for b in self.__begin): scan = True continue if any(code.startswith(e) for e in self.__end): scan = False if scan: results[sha1] = info return results.items() # Try to find related commits for the conflicts def check_conflicts(module, config, from_ref, conflicts): results = '' blameparser = BlameParser() for conflict in conflicts: changes = blameparser.parse(conflict) results += conflict + ':\n\t' + '\n\t'.join('%s %s' %c for c in changes) + '\n' return results # Replace all occurrences of searchExp in one file def replaceInFile(file, searchExp, replaceExp): import fileinput for line in fileinput.input(file, inplace=1): line = re.sub(searchExp, replaceExp, line) sys.stdout.write(line) # Reset module to destination branch def reset_module(module, config): opts = {} opts['to'] = config.branch_to try: git_checkout = "git checkout %(to)s" %opts subprocess.check_call(git_checkout.split(), stdout=fnull) git_reset = "git reset --hard origin/%(to)s" %opts subprocess.check_call(git_reset.split(), stdout=fnull, stderr=fnull) except Exception as e: logging.error("Git reset failed: %s", str(e)) return False return True # Run git merge for one module def do_merge(module, config, from_ref): try: cmd_git_merge = ["git", "merge", "-m", \ "Merge remote-tracking branch \'origin/" + config.branch_from + "\' into " + config.branch_to, \ from_ref, "--no-edit", "--no-ff"] ret = subprocess.call(cmd_git_merge, stdout=fnull) if ret != 0 and config.mergetool: print("Starting mergetool") ret = subprocess.call(['git', 'mergetool', '-y']) if ret == 0: ret = subprocess.call(['git', 'commit', '--no-edit'], stdout=fnull) if ret != 0: print("Module %s failed to merge, manual merge needed." % module) return False # Make sure we get a change-id as git merge doesn't generate it for us git_amend = "git commit --amend --no-edit" subprocess.check_call(git_amend.split(), stdout=fnull) except Exception as e: logging.error("Merge failed: %s", str(e)) return False return True def wip_merge(module, config, from_ref): conflicts = [] try: cmd_git_status = ["git", "status", "--porcelain"] status_output = subprocess.check_output(cmd_git_status).decode('utf-8').strip() status_count = 0 if status_output: lines = status_output.split('\n') status_count = len(lines) for line in lines: try: mode, file = line.split() except (ValueError, TypeError): pass # not exactly two tokens else: if 'U' in mode: print("conflict: ", file) conflicts.append(file) if not conflicts: print("Didn't find any conflict. Need to check carefully.") return False cmd_git_commit = ["git", "commit", "-a", "-m", "WIP: Merge remote-tracking branch \'origin/" + config.branch_from + "\' into " + config.branch_to + "\n" + '\nConflicts:\n\t' + '\n\t'.join(conflicts) + '\n'] ret = subprocess.call(cmd_git_commit, stdout=fnull) if ret != 0: print("Failed to do \"git commit -a\".") return False # Make sure we get a change-id as git merge doesn't generate it for us git_amend = "git commit --amend --no-edit" subprocess.check_call(git_amend.split(), stdout=fnull) related = check_conflicts(module, config, from_ref, conflicts) print("Related: ", related) except Exception as e: logging.error("Create WIP change failed: %s", str(e)) return False return True # Change the version number in the .qmake.conf file to a given string def update_qmake_conf(module, version): fileName = ".qmake.conf" replaceInFile(fileName, "5\..\..", version) cmd_git_status = "git status".split() status = subprocess.check_output(cmd_git_status).decode('utf-8') print(status) if status.find(fileName) < 0: return False cmd_git_add = "git add .qmake.conf".split() subprocess.check_call(cmd_git_add, stdout=fnull) cmd_git_commit = ['git', 'commit', '-m', 'Update module version to %s' % version] subprocess.check_call(cmd_git_commit, stdout=fnull) return True # Run git push to the destination branch for one module def push_gerrit(module, config): subprocess.call(["git", "show"]) print('\n"%s" was successfully updated and merged' % module) # fix for python 2.x compatibility try: input = raw_input except NameError: pass confirm = input('Push merge? [Y/n] ') if not confirm or confirm.startswith(('y', 'Y')): output = subprocess.check_output(('git push gerrit HEAD:refs/for/%s' % config.branch_to).split()).decode('utf-8') #url = re.search("https.*", output).group(0).strip() #print(url) #GERRIT_CHANGE_ID=$(echo "$GIT_PUSH" | sed 's/.*https:\/\/codereview.qt-project.org\/\(.*\)/\;tx;d;:x') #echo "ChangeId: $GERRIT_CHANGE_ID" # if we had gerrit 2.4, we could do this: #for reviewer in # sh codereview.qt-project.org gerrit set-reviewers --add jedrzej.nowacki@digia.com 49938 else: print(colors.WARNING + "Merge not pushed." + colors.ENDC) # Perform merge and push it for one module def merge(module, config, from_ref): push_required = False if config.merge or config.wip: if not do_merge(module, config, from_ref): return False push_required = True if config.version: print('Updating .qmake.conf for', module) if update_qmake_conf(module, config.version): push_required = True if push_required: push_gerrit(module, config) return True # Create WIP change when having conflicts def create_wip(module, config, from_ref): if config.wip: if not wip_merge(module, config, from_ref): return False push_gerrit(module, config) return True def get_module_sha_from_super(module, branch): result = "" cmd_git_lstree = ["git", "ls-tree", "origin/" + branch, module] lstree_output = subprocess.check_output(cmd_git_lstree).decode('utf-8').strip() lstree_outputs = lstree_output.split() if len(lstree_outputs) == 4 and lstree_outputs[3] == module: result = lstree_outputs[2].strip() return result # Iterate over all modules (either default or passed with -m option) def process_modules(config): if config.list_modules: print("Modules: ", config.modules) return cmd_git_fetch = ["git", "fetch"] subprocess.check_call(cmd_git_fetch, stdout=fnull, stderr=fnull) manual_merges = [] for module in config.modules.split(): print("\nModule: " + colors.GREEN + module + colors.ENDC) if not os.path.isdir(module): print("Directory '%s' does not exist. Skipping..." % module) continue try: os.chdir(module) try: cmd_git_fetch = ["git", "fetch"] subprocess.check_call(cmd_git_fetch, stdout=fnull, stderr=fnull) to_string = "origin/" + config.branch_to from_string = "origin/" + config.branch_from if config.super: os.chdir("..") from_sha1 = get_module_sha_from_super(module, config.branch_from) if from_sha1: from_string = from_sha1 else: print("Unable to obtain sha1 from super module for module ", module, "\n") os.chdir(module) print("Merge from", from_string, "to", to_string, ":") cmd_git_cherry = ["git", "cherry", to_string, from_string, "-v"] cherry_output = subprocess.check_output(cmd_git_cherry).decode('utf-8').strip() change_count = 0 if cherry_output: change_count = len(cherry_output.split('\n')) if config.status: print(cherry_output) print(colors.GREEN, change_count, colors.ENDC, "patches to be merged in", module, "\n") if config.reset or config.merge or config.version or config.wip: reset_module(module, config) if change_count > 0: if not merge(module, config, from_string): manual_merges.append(module) if config.wip: create_wip(module, config, from_string) except Exception as e: logging.error("Command execution failed: %s", str(e)) import traceback traceback.print_exc(file=sys.stdout) finally: os.chdir("..") except Exception as e: logging.error("Changing current dir failed: %s", str(e)) import traceback traceback.print_exc(file=sys.stdout) if manual_merges: print("Modules failed to merge: ", manual_merges) # get a list of submodules and a list of submodules that are not checked out def get_submodules(): git_submodule_status = subprocess.check_output('git submodule status'.split(' ')).strip() modules = [] modules_not_checked_out = [] for line in git_submodule_status.split('\n'): module = line.strip().split(' ')[1] if line.startswith('-'): modules_not_checked_out.append(module) print('WARNING:', module, 'is not checked out') elif module in ('qtqa', 'qtrepotools'): print('skipping', module) else: modules.append(module) return modules, modules_not_checked_out if __name__== "__main__": import argparse parser = argparse.ArgumentParser(prog="git-qt-merge-branches", description="Merge branches for the Qt Project", epilog=""" Run from within a work tree of the qt5 super-module, with all relevant sub-modules checked out (e.g. using qt5/init-repository). """) group = parser.add_argument_group("Primary action", "Chose one of:") group.add_argument('-s', '--status', action="store_true", help='show the status (which patches will be merged)') group.add_argument('-d', '--merge', action="store_true", help='do the merge') parser.add_argument('-u', '--super', action="store_true", help='obtain sha1s from supermodule instead of using branch tips') parser.add_argument('-m', '--modules', help='override the list of modules (eg. -m "qtbase qtdeclarative")') group.add_argument('-l', '--list-modules', action="store_true", help='list the modules to be merged and exit') group.add_argument('--reset', action="store_true", help='reset to origin/to_branch. this is implicit in the merge command') parser.add_argument('-f', '--branch-from', default='stable', help='from which branch to merge') parser.add_argument('-t', '--branch-to', default='dev', help='the target branch') group.add_argument('-v', '--version', default='', help='set version in .qmake.conf to given version string') parser.add_argument('--mergetool', action="store_true", help='run mergetool for conflicts') parser.add_argument('-w', '--wip', action="store_true", help='create WIP change when conflicts arise') config = parser.parse_args() print("Qt Project merge tool\n") default_modules, modules_not_checked_out = get_submodules() if config.modules is None: config.modules = ' '.join(default_modules) print("Submodules: ", default_modules) print("Ignored submodules: ", modules_not_checked_out) logging.basicConfig(format='%(levelname)s: %(message)s') if config.status or config.merge or config.reset or config.list_modules or config.version or config.wip: process_modules(config) else: parser.print_help() print("Submodules that are not checked out and are ignored:\n", ' '.join(modules_not_checked_out))