#!/bin/env python3 # Copyright (C) 2020 Denis 'GNUtoo' Carikli # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU Affero General Public License for more details. # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . import argparse import configparser import enum import os import re import sys import sh # Settings cover_mail_template = """Hi, In addition to the {patch} that will follow in a response to this mail, here's an URL to the see the {patch} in a web interface: {web_url} And here's how to get {it} in a git repository: git clone {clone_repo} cd {repo_name} git show {commit} {signature} """ class RemoteNotFoundError(Exception): pass def usage(progname): output = "" try: output = sh.git("format-patch", "-h", _ok_code=129) except: pass output = output.replace('git format-patch', '{} [-C ]'.format(progname)) found = False for line in output.split(os.linesep): if line == "" and not found: print("") print(" -C Run as if it was started in instead of the current working directory.") print("") found = True else: print(line, sep='') sys.exit(1) def get_config(): # This should implement the XDG Base Directory Specification which is # available here: # https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html config_file = None xdg_config_home = '' try: xdg_config_home = os.environ['XDG_CONFIG_HOME'] except KeyError: pass if xdg_config_home == '': try: xdg_config_home = os.environ['HOME'] + os.sep + '.config' except KeyError: # This follow strictly the specification xdg_config_home = os.sep + '.config' xdg_config_dirs = '' try: xdg_config_dirs = os.environ['XDG_CONFIG_DIRS'] except KeyError: pass if xdg_config_dirs == '': xdg_config_dirs = os.sep + 'etc' + os.sep + 'xdg' for base in [xdg_config_home] + xdg_config_dirs.split(os.pathsep): config_path = base + os.sep + 'replicant' + os.sep \ + 'replicant_prepare_patch.conf' if not os.path.isfile(config_path): continue try: # Silently skip the file in case of issues as per the specification config_file = open(config_path, 'r') except: pass if config_file: break if config_file is None: return None config = configparser.ConfigParser() config.read_file(config_file) return config def match_array(regex_str, array): regex = re.compile(regex_str) for elm in array: if re.match(regex, elm): return True return False class GitRepo(object): def __init__(self, config, directory=None): self.config = config self.directory = directory if self.directory: self.git = sh.git.bake('-C', self.directory) else: self.git = sh.git.bake() def get_repo_url(self, remote=None): if remote is None: remote = self.config['local']['git_remote'] try: output = self.git('remote', 'get-url', remote) except: raise RemoteNotFoundError(remote) else: return output def get_repo_name(self): output = self.get_repo_url() output = os.path.basename(str(output)) output = re.sub(os.linesep, '', output) output = re.sub('\.git', '', output) return output # We want to generate a prefix to have the project name in it. # Examples: # - [libsamsung-ipc][PATCH] Fix IPC_SEC_LOCK_INFOMATION typo # - [device_samsung_i9300][PATCH] Add scripts to disable the modem # The revision is handled separately with git format-patch's -v switch def get_subject_prefix(self): repo_name = self.get_repo_name() # Try to autodetect the project name: # external_libsamsung-ipc -> libsamsung-ipc # device_samsung_i9300 -> device_samsung_i9300 dirs = repo_name.split('_') project_name = None if dirs[0] == "external": project_name = dirs[-1] elif dirs[0] == "hardware" and dirs[1] == "replicant": project_name = dirs[-1] elif dirs[0] == "device": project_name =repo_name else: project_name =repo_name if project_name == None: return None return '{project}] [PATCH'.format(project=project_name) def format_patch(self, git_format_patch_arguments): git_arguments = ['format-patch'] if not match_array('^--subject-prefix', git_format_patch_arguments): subject_prefix = self.get_subject_prefix() git_arguments.append('--subject-prefix={}'.format(subject_prefix)) git_arguments += git_format_patch_arguments patches = self.git(*git_arguments).split(os.linesep) patches.remove('') return patches def generate_cover_mail_text(self, commit, nr_patches, repo): cgit_url = 'https://git.replicant.us' web_url = '{base}/contrib/{user}/{repo}/commit/?id={commit}'.format( base=cgit_url, user=self.config['project']['username'], repo=repo, commit=commit) clone_repo = '{base}/{user}/{repo}'.format( base=cgit_url, user=self.config['project']['username'], repo=repo) signature = self.config['local']['mail_signature'] patch = 'patch' it = 'it' if nr_patches > 1: patch = 'patches' it = 'them' return cover_mail_template.format(patch=patch, it=it, web_url=web_url, clone_repo=clone_repo, commit=commit, repo_name=repo, signature=signature) def generate_git_send_email_command(self, git_revision, patches): command = ['send-email', '--compose', '--to={}'.format(self.config['project']['mailing_list'])] for patch in patches: if self.directory: command.append('{}/{}'.format(self.directory, patch)) else: command.append(patch) return command def get_commit_hash(self, git_revision): output = self.git('--no-pager', 'log', '--oneline', git_revision, '-1', '--format=%H') revision = re.sub(os.linesep, '', str(output)) return revision def get_top_commit_revision_from_range(self, rev_range, nr_patches): output = self.git('--no-pager', 'rev-parse', rev_range).split(os.linesep) output.remove('') # With git-format-patch, there are "two ways to specify which commits to # operate on.": # "1. A single commit, , [...] leading to the tip of the current # branch" # "2. Generic expression" # See man git-format-patch for more details on how to parse. # Single commit if len(output) == 1: output = self.git('--no-pager', 'rev-parse', rev_range + '..HEAD') output = output.split(os.linesep) output.remove('') return output[0] # Generic revision range else: return output[0] def get_git_revision_args_from_cmdline(cmdline): class Arg(enum.Enum): NONE = 0, REQUIRED = 1, OPTIONAL = 2, git_format_patch_opts = [ # shortopt, longopt, argument # TODO: --no-[option] # TODO: --base [ None, 'add-header', Arg.REQUIRED ], [ None, 'attach', Arg.OPTIONAL ], [ None, 'cc', Arg.REQUIRED ], [ None, 'cover-from-description', Arg.REQUIRED ], [ None, 'cover-letter', Arg.NONE ], [ None, 'creation-factor', Arg.REQUIRED ], [ None, 'filename-max-length', Arg.REQUIRED ], [ None, 'from', Arg.REQUIRED ], [ None, 'inline', Arg.OPTIONAL ], [ None, 'in-reply-to', Arg.REQUIRED ], [ None, 'interdiff', Arg.REQUIRED ], [ 'k', 'keep-subject', Arg.NONE ], [ None, 'no-attach', Arg.NONE ], [ None, 'no-binary', Arg.NONE ], [ None, 'no-indent-heuristic', Arg.NONE ], [ None, 'no-notes', Arg.NONE ], [ 'N', 'no-numbered', Arg.NONE ], [ None, 'no-renames', Arg.NONE ], [ None, 'no-relative', Arg.NONE ], [ None, 'no-signature', Arg.NONE ], [ 'p', 'no-stat', Arg.NONE ], [ None, 'no-thread', Arg.NONE ], [ None, 'notes', Arg.OPTIONAL ], [ 'n', 'numbered', Arg.NONE ], [ None, 'numbered-files', Arg.NONE ], [ 'o', 'output-directory', Arg.REQUIRED ], [ None, 'progress', Arg.NONE ], [ 'q', 'quiet', Arg.NONE ], [ None, 'range-diff', Arg.REQUIRED ], [ 'v', 'reroll-count', Arg.REQUIRED ], [ None, 'rfc', Arg.NONE ], [ None, 'signature', Arg.REQUIRED ], [ None, 'signature-file', Arg.REQUIRED ], [ 's', 'signoff', Arg.NONE ], [ None, 'start-number', Arg.REQUIRED ], [ None, 'stdout', Arg.NONE ], [ None, 'subject-prefix', Arg.REQUIRED ], [ None, 'suffix', Arg.REQUIRED ], [ None, 'thread', Arg.OPTIONAL ], [ None, 'to', Arg.REQUIRED ], [ None, 'zero-commit', Arg.NONE ], ] # We cannot use getopt because "Optional arguments [for long options] are # not supported". Reference: https://docs.python.org/3.8/library/getopt.html parser = argparse.ArgumentParser() parser.add_argument('args', nargs=argparse.REMAINDER) for option in git_format_patch_opts: if option[2] == Arg.REQUIRED: if option[0] != None: parser.add_argument('-' + option[0], nargs=1) if option[1] != None: parser.add_argument('--' + option[1], nargs=1) elif option[2] != Arg.OPTIONAL: if option[0] != None: parser.add_argument('-' + option[0], nargs='?') if option[1] != None: parser.add_argument('--' + option[1], nargs='?') else: # For now, we can filter out those with - or -- continue remaining_args = parser.parse_args(cmdline).__dict__.get('args', []) revision = remaining_args[0] return revision def get_git_revision_range_from_args(args): if git_revision_args.startswith('-'): nr_patches = int(args[1:]) return 'HEAD~{}..HEAD'.format(nr_patches) else: return args if __name__ == '__main__': directory = None patches = None progname = sys.argv.pop(0) if len(sys.argv) == 0: usage(progname) config = get_config() if config == None: print('Failed to find a configuration file') sys.exit(1) if sys.argv[0] == '-C': sys.argv.pop(0) directory = sys.argv.pop(0) repo = GitRepo(config, directory) try: patches = repo.format_patch(sys.argv) except RemoteNotFoundError as e: if len(e.args) > 0: remote = e.args[0] print("error: git remote \"{}\" not found.".format(remote)) print("{}To add it you can use \"git remote add {} {}\".".format( " ", remote, "git://git.replicant.us/contrib//")) print(" " + "If you can't create repositories in git.replicant.us, " + "please contact us on the Replicant mailing list.") else: print("internal error: no git remote given.") print(" " + "Please report a bug in the Replicant mailing list " + "or bug tracker.") sys.exit(os.EX_SOFTWARE) except: usage(progname) print('patches:') print('--------') for patch in patches: if directory: print('{}/{}'.format(directory, patch)) else: print(patch) git_revision_args = get_git_revision_args_from_cmdline(sys.argv) git_revision_range = get_git_revision_range_from_args(git_revision_args) top_revision = repo.get_top_commit_revision_from_range(git_revision_range, len(patches)) print() print('git command:') print('------------') print('git ' + ' '.join( repo.generate_git_send_email_command(git_revision_range, patches))) print() print('Cover mail:') print('-----------') print(repo.generate_cover_mail_text(top_revision, len(patches), repo.get_repo_name()))