Created
November 18, 2015 15:14
-
-
Save skliarpawlo/42c51b9798e7bf88b3e1 to your computer and use it in GitHub Desktop.
git hooks
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env python3 | |
# -*- coding: utf-8 -*- | |
import os | |
import re | |
import sys | |
EMO = os.environ.get('TUBULAR_COMMIT_EMOJI', '👮 ') | |
SHORT_DESCRIPTION_LINE_REGEX = re.compile('^((?:[A-Z]+-\d+)|FIX|HOTFIX|IMP) (.{1,50})$') | |
EMOJI_REGEX = re.compile('^:.*:$') | |
def green(msg): | |
return '\033[92m{}\033[0m'.format(msg) | |
def yellow(msg): | |
return '\033[93m{}\033[0m'.format(msg) | |
def red(msg): | |
return '\033[91m{}\033[0m'.format(msg) | |
def cyan(msg): | |
return '\033[96m{}\033[0m'.format(msg) | |
def read_commit_message(): | |
msg_file = sys.argv[1] | |
with open(msg_file) as f: | |
msg = f.readlines() | |
return msg | |
def check_commit_message(msg): | |
# filter comments | |
msg = [line for line in msg if not line.startswith('#')] | |
bump_match = EMOJI_REGEX.search(msg[0]) | |
if bump_match is not None: | |
return True | |
match = SHORT_DESCRIPTION_LINE_REGEX.search(msg[0]) | |
if match is None: | |
sys.stdout.write(red('ERROR: first line is not formatted correctly\n')) | |
sys.stdout.write(yellow('Hint: check if it starts with XXXX-1234<space> or ' | |
'FIX|HOTFIX|IMP<space>\n')) | |
sys.stdout.write(yellow('Hint: check if short message is in lowercase and ' | |
'contains less then 50 symbols\n')) | |
return False | |
groups = match.groups() | |
short_message = groups[1] | |
if not short_message[0].islower(): | |
sys.stdout.write(red('ERROR: short description is not in lowercase\n')) | |
return False | |
if len(msg) > 1 and msg[1].strip() != '': | |
sys.stdout.write(red('ERROR: second line is not empty\n')) | |
return False | |
return True | |
def main(): | |
sys.stdin = open('/dev/tty') | |
msg = read_commit_message() | |
sys.stdout.write(cyan('{} Checking commit message\n'.format(EMO))) | |
if not check_commit_message(msg): | |
quit = input('Commit message does not conform TEP-35, do you want ' | |
'to fix that? [Y/n] ').strip().lower() | |
if quit != 'n': | |
return 1 | |
else: | |
sys.stdout.write(green('SUCCESS - ')) | |
sys.stdout.write(cyan('Ready to commit\n\n')) | |
return 0 | |
sys.exit(main()) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env python3 | |
# -*- coding: utf-8 -*- | |
import os | |
import re | |
import subprocess | |
import sys | |
NON_EXISTENT_MOCK_METHODS = [ | |
'assert_calls', | |
'assert_not_called', | |
'assert_called', | |
'assert_called_once', | |
'not_called', | |
'called_once', | |
] | |
EMO = os.environ.get('TUBULAR_COMMIT_EMOJI', '👮 ') | |
MODIFIED = re.compile(r'^(?P<status>\w)\s+(?P<name>.*)') | |
COMMIT_CHECKS = ( | |
{ | |
'output': 'Checking for console.log()... ', | |
'command': 'grep -n -e "[^\/\/]console.log" {0}', | |
'match_files': ['static\/.*\.(js|coffee)$'], | |
'print_filename': True, | |
'only_warn': False | |
}, | |
{ | |
'output': 'Checking for alert()... ', | |
'command': 'grep -n alert\( {0}', | |
'match_files': ['.*\.[js|coffee]$'], | |
'print_filename': True, | |
'only_warn': False | |
}, | |
{ | |
'output': 'Checking for toxic whitespace... ', | |
'command': 'file {0} | grep -q text && grep -n -e " $" {0}', | |
'match_files': ['.*'], | |
'ignore_files': ['.*\.whl$', '.*\.(GET|POST|PUT|DELETE|HEAD)$', '.*\.(sql|json)'], | |
'print_filename': True, | |
'only_warn': False | |
}, | |
{ | |
'output': 'Checking for print statements... ', | |
'command': "grep -n -E '(^|\s)print[ \(]' {0} | sed -n '/\ file=/!p'", | |
'match_files': ['.*\.py$'], | |
'print_filename': True, | |
'only_warn': True | |
}, | |
{ | |
'output': 'Checking for non existent mock methods(... ', | |
'command': "grep -n -e '\(\." + '\|\.'.join(NON_EXISTENT_MOCK_METHODS) + "\)(' {0} | sed -n '/\ file=/!p'", | |
'match_files': ['.*\.py$'], | |
'print_filename': True, | |
'only_warn': False | |
}, | |
{ | |
'output': 'Checking PyFlakes... ', | |
'command': 'pyflakes {0}', | |
'match_files': ['.*\.py$'], | |
'print_filename': True, | |
'only_warn': False | |
}, | |
# PEP8 is the canonical Python style guide. We're going to be using | |
# it with a few exceptions: | |
# * E501 which suggests 79 character line length can be ignored | |
# * E221 which wants no extra spaces in assignment will be ignored | |
# * E712 comparison to True (this is useful but peewee borks it) | |
# * E126 which wants a hanging indent to be visually aligned | |
# | |
# For everything else, see: | |
# https://www.python.org/dev/peps/pep-0008 | |
{ | |
'output': 'Checking pep8... ', | |
'command': 'pep8 {0} --ignore=E501,E221,E712,E126', | |
'match_files': ['.*\.py$'], | |
'print_filename': True, | |
'only_warn': True | |
}, | |
# Readability counts. While pep8 suggests 79 characters, a quick | |
# poll suggests that number is too austere for most. We're going | |
# with a 99 character max. Please note that matching 100 characters | |
# means an effective line length of 99, which gives room on your screen | |
# for 1 column with a line break or wrapper glyph (if any) when you | |
# set your editor at 100. | |
# | |
# See a well written explanation here: | |
# https://www.python.org/dev/peps/pep-0008/#maximum-line-length | |
{ | |
'output': 'Checking for line length... ', | |
'command': "egrep -n '.{{100}}' {0}", | |
'match_files': ['.*\.py$'], | |
'print_filename': True, | |
'only_warn': True | |
} | |
) | |
def green(msg): | |
return '\033[92m{}\033[0m'.format(msg) | |
def yellow(msg): | |
return '\033[93m{}\033[0m'.format(msg) | |
def red(msg): | |
return '\033[91m{}\033[0m'.format(msg) | |
def cyan(msg): | |
return '\033[96m{}\033[0m'.format(msg) | |
def matches_file(file_name, match_files): | |
return any(re.compile(match_file).match(file_name) for match_file in match_files) | |
def kick_offending_file_out_of_staging(file_name): | |
subprocess.call(['git', 'reset', 'HEAD', file_name], | |
stdout=subprocess.PIPE, | |
stderr=subprocess.PIPE) | |
def list_submodules(): | |
shell_cmd = "cd $(git rev-parse --show-toplevel) && " \ | |
"git submodule summary | grep \"^\*\" | cut -f2 -d' ' && " \ | |
"cd - > /dev/null" | |
out, err = subprocess.Popen([shell_cmd], stdout=subprocess.PIPE, shell=True).communicate() | |
submodules = out.decode().strip().split('\n') | |
return submodules | |
def check_files(files, check, end_result): | |
result = 0 | |
warnings = False | |
sys.stdout.write(check['output']) | |
for status, file_name in files: | |
if status in ['D']: | |
continue | |
if file_name in list_submodules(): | |
continue | |
if 'match_files' not in check or matches_file(file_name, check['match_files']): | |
if 'ignore_files' not in check or not matches_file(file_name, check['ignore_files']): | |
process = subprocess.Popen(check['command'].format(file_name), | |
stdout=subprocess.PIPE, | |
stderr=subprocess.PIPE, shell=True) | |
out, err = process.communicate() | |
if err: | |
result = 1 | |
sys.stdout.write(yellow(err)) | |
if out: | |
if check['print_filename']: | |
prefix = '\t%s:' % file_name | |
else: | |
prefix = '\t' | |
output_lines = ['%s%s' % (prefix, line) for line in out.splitlines()] | |
if check['only_warn']: | |
warnings = True | |
sys.stdout.write(yellow('\n' + '\n'.join(output_lines) + '\n')) | |
else: | |
result = 1 | |
sys.stdout.write(red('\n' + '\n'.join(output_lines) + '\n')) | |
kick_offending_file_out_of_staging(file_name) | |
if warnings and end_result == 0: | |
quit = input( | |
'Would you like to quit and handle the found warnings? ' | |
'Press \'a\' - to run autopep and retry [Y/n/a] ').strip().lower() | |
if quit == 'a': | |
sys.stdout.write(cyan('Running pre-commit hook autopep8\n')) | |
pep8ify(files) | |
main() | |
sys.exit(0) | |
if quit != 'n': | |
sys.exit(-1) # Need a non-zero exit to not commit | |
if result == 0 and not warnings: | |
sys.stdout.write(green('OK\n')) | |
return result | |
def pep8ify(files): | |
with_errors = False | |
for mode, file_path in files: | |
if not os.path.exists(file_path): | |
continue | |
if not file_path.endswith('.py'): | |
continue | |
sys.stdout.write(cyan('Auto pep8ifing files in staging')) | |
proc = subprocess.Popen( | |
'autopep8 --in-place --aggressive --ignore=E501,E221,E712,E126 {file_path}'.format( | |
file_path=file_path, | |
), | |
stdout=subprocess.PIPE, | |
stderr=subprocess.PIPE, | |
shell=True) | |
out, err = proc.communicate() | |
if err: | |
sys.stdout.write(red('FAIL - ')) | |
sys.stdout.write(cyan('Could not autopep8ify file {file_path}'.format( | |
file_path=file_path | |
))) | |
with_errors = True | |
else: | |
sys.stdout.write(green('OK - ')) | |
sys.stdout.write(cyan('{file_path}'.format( | |
file_path=file_path | |
))) | |
sys.stdout.write(cyan('Adding reformatted file to staging')) | |
git_proc = subprocess.Popen( | |
'git add {file_path}'.format( | |
file_path=file_path, | |
), | |
stdout=subprocess.PIPE, | |
stderr=subprocess.PIPE, | |
shell=True) | |
out, err = git_proc.communicate() | |
if err: | |
sys.stdout.write(red('FAIL - ')) | |
sys.stdout.write(cyan('could not add file {file_path} to staging'.format( | |
file_path=file_path | |
))) | |
with_errors = True | |
if with_errors: | |
sys.stdout.write(red('\nFINISHED WITH ERRORS\n')) | |
else: | |
sys.stdout.write(green('\nSUCCESS\n')) | |
def run_tests(): | |
sys.stdout.write('Running unittests... ') | |
sys.stdout.flush() | |
proc = subprocess.Popen( | |
'TUBULAR_SETTINGS_ENV=test nosetests .', | |
stdout=subprocess.PIPE, | |
stderr=subprocess.PIPE, | |
shell=True) | |
out, err = proc.communicate() | |
if proc.returncode: | |
sys.stdout.write(red('FAIL - ')) | |
sys.stdout.write(cyan('Tests failed\n')) | |
sys.stdout.write(err.decode('utf-8')) | |
return proc.returncode | |
sys.stdout.write(green('OK\n')) | |
return 0 | |
def main(): | |
"""Main entry point for programme to lint stdin lines or staged/cached git files.""" | |
end_result = 0 | |
# For the Jenkins test we need to be able to pipe to this linter | |
stdin_lines = sys.stdin.readlines() | |
if stdin_lines: | |
assert hasattr(MODIFIED.match(stdin_lines[0]), 'groups'), 'Input does not match pattern' | |
files = [MODIFIED.match(line).groups() for line in stdin_lines] | |
# Get list of files that are staged/cached in GIT (doesn't include untracked or modified files) | |
else: | |
out = subprocess.check_output([ | |
'git', 'diff-index', '--cached', '--name-status', 'HEAD' | |
]) | |
files = [MODIFIED.match(line.decode('utf8')).groups() for line in out.splitlines()] | |
sys.stdin = open('/dev/tty') # Default fd=0 is /dev/null | |
if files: | |
sys.stdout.write( | |
cyan('{} Running pre-commit hook code validation on staged files\n'.format(EMO)) | |
) | |
for check in COMMIT_CHECKS: | |
end_result = check_files(files, check, end_result) or end_result | |
if os.environ.get('TUBULAR_PRECOMMIT_RUNTESTS', False): | |
end_result = end_result or run_tests() | |
if end_result == 0: | |
sys.stdout.write(green('SUCCESS - ')) | |
sys.stdout.write(cyan('Ready to commit\n\n')) | |
else: | |
sys.stdout.write(red('FAIL - ')) | |
sys.stdout.write(cyan('Not committing. Unstaged offending files. ' | |
'Don\'t forget to `git add` these files after fixing them.\n')) | |
return end_result | |
if __name__ == '__main__': | |
sys.exit(main()) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment