blob: 86da3cd1ed75b81556dde713701dfb2b9d6356f5 [file] [log] [blame] [edit]
#!/usr/bin/env {{ python }}
import os
import re
import subprocess
import sys
TOPLEVEL = subprocess.run(['git', 'rev-parse', '--show-toplevel'], encoding='utf-8', capture_output=True, check=True).stdout.strip()
LOCATION = os.path.join(TOPLEVEL, r'{{ location }}')
SPACING = 8
IDENT = 4
SCRIPTS = os.path.dirname(os.path.dirname(LOCATION))
PREPARE_CHANGELOG_CMD = [{{ perl }}, os.path.join(SCRIPTS, 'prepare-ChangeLog'), '--no-write', '--only-files', '--git-index']
CHERRY_PICKING_RE = re.compile(r'^# You are currently cherry-picking commit (?P<hash>[a-f0-9A-F]+)\.$')
CHERRY_PICK_COMMIT_RE = re.compile(r'^(?P<a>\S+)( \((?P<b>\S+)\))?$')
REFNAME_RE = re.compile(r'^refs/remotes/(?P<remote>[^/ ]+)/(?P<branch>\S+)$')
HASH_RE = re.compile(r'^[a-f0-9A-F]+$')
IDENTIFIER_RE = re.compile(r'(\d+\.)?\d+@\S+')
PREFER_RADAR = {{ prefer_radar }}
DEFAULT_BRANCH = '{{ default_branch }}'
SOURCE_REMOTES = {{ source_remotes }}
TRAILERS_TO_STRIP = {{ trailers_to_strip }}
COMMIT_REF_BASE = r'r?R?[a-f0-9A-F]+(\.\d+)?@?([0-9a-zA-z\-\/\.]+[0-9a-zA-z\-\/])?'
COMPOUND_COMMIT_REF = r'(?P<primary>{})(?P<secondary> \({}\))?'.format(COMMIT_REF_BASE, COMMIT_REF_BASE)
CHERRY_PICK_RE = [
re.compile(r'\S* ?[Cc]herry[- ][Pp]ick of {}'.format(COMPOUND_COMMIT_REF)),
re.compile(r'\S* ?[Cc]herry[- ][Pp]ick {}'.format(COMPOUND_COMMIT_REF)),
re.compile(r'\S* ?[Cc]herry[- ][Pp]icked {}'.format(COMPOUND_COMMIT_REF)),
]
UNPACK_SECONDARY_RE = re.compile(r' \(({})\)'.format(COMMIT_REF_BASE))
sys.path.append(SCRIPTS)
from webkitpy.common.checkout.diff_parser import DiffParser
from webkitbugspy import radar
def set_env_variables_from_branch_config():
BRANCH = subprocess.run(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], encoding='utf-8', capture_output=True, check=True).stdout.strip()
for name, data in dict(
title=('COMMIT_MESSAGE_TITLE', 1),
bug=('COMMIT_MESSAGE_BUG', re.compile(r'^(\D+)\d+$')),
cherry_picked=('GIT_WEBKIT_CHERRY_PICKED', 1),
).items():
variable, mode = data
if variable in os.environ and os.environ[variable]:
continue
try:
value = ''
count = 0
for line in reversed(subprocess.run(
['git', 'config', '--get-all', 'branch.{}.{}'.format(BRANCH, name)],
capture_output=True,
encoding='utf-8',
check=True
).stdout.strip().splitlines()):
if isinstance(mode, int) and count >= mode:
break
elif isinstance(mode, re.Pattern):
match = mode.match(line)
if match and match.group(1) in value:
continue
if line:
value += line + '\n'
count += 1
value = value.strip()
if value:
os.environ[variable] = value
except subprocess.CalledProcessError as error:
# `1` means that key did not exist, which is valid.
if error.returncode != 1:
sys.stderr.write(error.stderr)
def get_bugs_string():
"""Determines what bug URLs to fill or suggest in a WebKit commit message."""
need_the_bug_url = 'Need the bug URL (OOPS!).'
need_the_radar = 'Include a Radar link (OOPS!).'
has_radar = any([bool(regex.match(line))
for line in os.environ.get('COMMIT_MESSAGE_BUG', '').splitlines()
for regex in radar.Tracker.RES])
if os.environ.get('COMMIT_MESSAGE_BUG'):
if has_radar or not bool(radar.Tracker.radarclient()):
return os.environ['COMMIT_MESSAGE_BUG']
else:
return os.environ['COMMIT_MESSAGE_BUG'] + '\n' + need_the_radar
bugs_string = need_the_bug_url
if bool(radar.Tracker.radarclient()):
bugs_string += '\n' + need_the_radar
return bugs_string
def parseChanges(command, commit_message):
dirname = None
changes = []
try:
for line in subprocess.run(
command,
encoding='utf-8',
capture_output=True,
check=True
).stdout.splitlines():
commit_message.append(line[SPACING:])
changes.append(line[SPACING:])
except subprocess.CalledProcessError:
commit_message.append('')
return
return changes
def message(source=None, sha=None, mention_tests=True):
commit_message = []
amend_changes = None
command = PREPARE_CHANGELOG_CMD
if not mention_tests:
command = command + ["--no-mention-tests"]
if sha:
commit_message.append('Amend changes:')
amend_changes = parseChanges(command, commit_message)
commit_message.append('')
commit_message.append('Combined changes:')
command += ['--git-commit', 'HEAD']
combined_changes = parseChanges(command, commit_message)
revert_msg = os.environ.get('COMMIT_MESSAGE_REVERT', '')
if revert_msg:
return '''{title}\n{revert}'''.format(
title=os.environ.get('COMMIT_MESSAGE_TITLE', ''),
revert=revert_msg,
), combined_changes, amend_changes
else:
bugs_string = get_bugs_string()
return '''{title}
{bugs}
Reviewed by NOBODY (OOPS!).
Explanation of why this fixes the bug (OOPS!).
{content}
'''.format(
title=os.environ.get('COMMIT_MESSAGE_TITLE', '') or 'Need a short description (OOPS!).',
bugs=bugs_string,
content='\n'.join(commit_message) + os.environ.get('COMMIT_MESSAGE_CONTENT', ''),
), combined_changes, amend_changes
def source_branches():
proc = None
try:
proc = subprocess.Popen(
['git', 'rev-list', 'HEAD'],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
encoding='utf-8',
)
outer_line = proc.stdout.readline()
while outer_line:
branches = set()
for inner_line in subprocess.run(
['git', 'branch', '--contains', outer_line.strip(), '-a', '--format', '%(refname)'],
encoding='utf-8',
capture_output=True,
check=True
).stdout.splitlines():
if not inner_line.startswith('refs/remotes'):
continue
_, _, remote, branch = inner_line.split('/', 3)
if remote in SOURCE_REMOTES and branch != 'HEAD':
branches.add(branch)
if branches:
return sorted(branches)
outer_line = proc.stdout.readline()
finally:
if proc and proc.poll() is None:
proc.kill()
return []
def cherry_pick(content):
cherry_picked = os.environ.get('GIT_WEBKIT_CHERRY_PICKED')
bugs = os.environ.get('COMMIT_MESSAGE_BUG', '').splitlines()
bug = bugs[0] if bugs else None
if PREFER_RADAR and not bug:
RADAR_RES = [
re.compile(r'<?rdar://problem/(?P<id>\d+)>?'),
re.compile(r'<?radar://problem/(?P<id>\d+)>?'),
re.compile(r'<?rdar:\/\/(?P<id>\d+)>?'),
re.compile(r'<?radar:\/\/(?P<id>\d+)>?'),
]
seen_empty = False
for line in content.splitlines():
if not line and seen_empty:
break
elif not line:
seen_empty = True
continue
words = line.split()
for word in [words[0], words[-1]] if words[0] != words[-1] else [words[0]]:
for regex in RADAR_RES:
if regex.match(word):
bug = word
break
if bug:
break
if not cherry_picked or not bug:
LINK_RE = re.compile(r'^\S+:\/\/\S+\d+\S*$')
last_line = ''
for line in content.splitlines():
if not line:
continue
if not line.startswith('#'):
last_line = line
if not bug and LINK_RE.match(line):
bug = line
match = None if cherry_picked else CHERRY_PICKING_RE.match(line)
if match:
cherry_picked = match.group('hash')
if bug and cherry_picked:
break
if last_line and (not cherry_picked or not IDENTIFIER_RE.search(cherry_picked)):
from_last_line = IDENTIFIER_RE.search(last_line)
if from_last_line and not cherry_picked:
cherry_picked = from_last_line.group(0)
elif from_last_line:
cherry_picked = '{} ({})'.format(from_last_line.group(0), cherry_picked)
production_hash = None
match = CHERRY_PICK_COMMIT_RE.match(cherry_picked)
for candidate in ([match.group('a'), match.group('b')] if match else []):
if not candidate:
continue
if HASH_RE.match(candidate):
production_hash = candidate
if production_hash:
try:
subprocess.run(
['git', 'merge-base', '--is-ancestor', production_hash, DEFAULT_BRANCH],
encoding='utf-8', check=True
)
except subprocess.CalledProcessError:
remotes = set()
for line in subprocess.run(
['git', 'branch', '--contains', production_hash, '-a', '--format', '%(refname)'],
encoding='utf-8',
capture_output=True,
check=True
).stdout.splitlines():
match = REFNAME_RE.match(line.strip())
if not match:
continue
remote = match.group('remote')
if remote in SOURCE_REMOTES:
remotes.add(match.group('remote'))
if not remotes:
production_hash = None
is_trunk_bound = DEFAULT_BRANCH in source_branches()
in_suffix = False
result = []
unindent = False
lines = list(content.splitlines())
for header in CHERRY_PICK_RE:
match = header.match(lines[0])
if not match:
continue
cherry_picked = '{}{}'.format(match.group('primary'), match.group('secondary') or '')
unindent = True
lines = lines[1:]
while not lines[0].rstrip():
lines = lines[1:]
break
cherry_pick_metadata = '{}. {}'.format(cherry_picked or '???', bug or '<bug>')
if not is_trunk_bound:
result += ['Cherry-pick {}'.format(cherry_pick_metadata), '']
for line in lines:
line = line.rstrip()
if not line:
result.append('')
continue
if unindent and line.startswith(IDENT*' '):
line = line[IDENT:]
if line[0] == '#' and not in_suffix:
in_suffix = True
if is_trunk_bound and production_hash:
while len(result) and not result[-1] or (result[-1].split(':', 1)[0] in TRAILERS_TO_STRIP):
del result[-1]
result += ['', 'Originally-landed-as: {}'.format(cherry_pick_metadata)]
if line[0] != '#' and not is_trunk_bound and production_hash:
result.append(IDENT*' ' + line)
else:
result.append(line)
return '\n'.join(result)
def annotate_deleted_lines(amend_changes, combined_changes):
annotated_lines = []
for line in amend_changes:
if line not in combined_changes:
if line.startswith('*'):
if not line.endswith('Removed.'):
line += ' Removed.'
elif not line.endswith('Deleted.'):
line += ' Deleted.'
annotated_lines.append(line)
return annotated_lines
def amended_message(full_message, combined_changes, amend_changes, update_changelog):
if amend_changes and update_changelog:
try:
from webkitscmpy.commit_parser import CommitMessageParser
except ImportError as e:
sys.stderr.write(f'{e}: Could not update changelog automatically. Falling back to the default template.\n')
else:
commit_message_parser = CommitMessageParser()
commit_message_parser.parse_message(full_message)
updated_changelog_lines = commit_message_parser.apply_comments_to_modified_files_lines(combined_changes)
reviewed_by_lines = (commit_message_parser.reviewed_by_lines or ['Reviewed by NOBODY (OOPS!).'])
description_lines = (commit_message_parser.description_lines or ['Explanation of why this fixes the bug (OOPS!).'])
tests_lines = commit_message_parser.tests_lines + [''] if commit_message_parser.tests_lines else []
amended_message = commit_message_parser.title_lines + [''] + reviewed_by_lines + [''] + description_lines + [''] + tests_lines + updated_changelog_lines
removed_changelog_lines = commit_message_parser.apply_comments_to_modified_files_lines(annotate_deleted_lines(amend_changes, combined_changes), return_deleted=True)
return '''{message_body}
# Your changelog has been updated with the following files/functions:
{changed_lines}
# The following lines have been removed from the changelog:
{removed_lines}
# To temporarily disable this feature, run `git-webkit commit --amend --no-update`.
'''.format(message_body='\n'.join(amended_message),
changed_lines='\n'.join([f'# {file}' for file in amend_changes]),
removed_lines='\n'.join([f'# {file}' for file in removed_changelog_lines]))
commit_message = full_message
if not update_changelog:
commit_message += '''
# To automatically update your changelog when amending, run `git-webkit setup`
# or `git-webkit commit --amend --update` for one-time use.
'''
return commit_message
def main(file_name=None, source=None, sha=None):
with open(file_name, 'r') as commit_message_file:
content = commit_message_file.read()
if source not in (None, 'commit', 'template', 'merge'):
return 0
if os.environ.get('COMMIT_MESSAGE_TITLE', '').startswith('Unreviewed, reverting'):
pass
elif source == 'merge' and content.startswith('Revert'):
return 0
else:
set_env_variables_from_branch_config()
for line in content.splitlines():
if CHERRY_PICKING_RE.match(line):
os.environ['GIT_REFLOG_ACTION'] = 'cherry-pick'
break
if source == 'merge' and os.environ.get('GIT_REFLOG_ACTION') == 'cherry-pick':
with open(file_name, 'w') as commit_message_file:
commit_message_file.write(cherry_pick(content))
return 0
if os.environ.get('GIT_EDITOR', '') == ':':
# When there's no editor being launched, do nothing.
return 0
update_changelog = False
# Allow for overrides with git-webkit commit --amend [--update/--no-update]
if os.environ.get('WKSCMPY_UPDATE_CHANGELOG', '') == 'True':
update_changelog = True
elif os.environ.get('WKSCMPY_UPDATE_CHANGELOG', '') == 'False':
update_changelog = False
else:
try:
update_changelog = subprocess.run(
['git', 'config', '--get', '--type', 'bool', 'webkitscmpy.auto-update-changelog'],
encoding='utf-8',
capture_output=True,
check=True
).stdout.strip()
update_changelog = True if update_changelog == 'true' else False
except subprocess.CalledProcessError as error:
if error.returncode != 1:
sys.stderr.write(error.stderr)
with open(file_name, 'w') as commit_message_file:
if sha:
generated_msg, combined_changes, amend_changes = message(source=source, sha=sha, mention_tests=False)
git_log_msg = subprocess.run(
['git', 'log', sha, '-1', '--pretty=format:%B'],
encoding='utf-8',
capture_output=True,
check=True
).stdout
commit_message_file.write(amended_message(git_log_msg, combined_changes, amend_changes, update_changelog))
else:
generated_msg, combined_changes, amend_changes = message(source=source, sha=sha)
commit_message_file.write(generated_msg)
commit_message_file.write('''
# Please populate the above commit message. Lines starting with '#'
# will be ignored. For any files or functions that don't have an
# associated comment, please remove them from the commit message.
''')
if sha:
for line in generated_msg.splitlines():
commit_message_file.write('# {}\n'.format(line))
commit_message_file.write('\n')
for line in subprocess.run(
['git', 'status'],
encoding='utf-8',
capture_output=True,
check=True
).stdout.splitlines():
commit_message_file.write('# {}\n'.format(line))
return 0
if __name__ == '__main__':
sys.exit(main(*sys.argv[1:]))