aboutsummaryrefslogtreecommitdiff
path: root/pre-upload.py
diff options
context:
space:
mode:
Diffstat (limited to 'pre-upload.py')
-rwxr-xr-xpre-upload.py382
1 files changed, 262 insertions, 120 deletions
diff --git a/pre-upload.py b/pre-upload.py
index 0109133..3eece2d 100755
--- a/pre-upload.py
+++ b/pre-upload.py
@@ -20,9 +20,12 @@ when developing.
"""
import argparse
+import concurrent.futures
import datetime
import os
+import signal
import sys
+from typing import List, Optional
# Assert some minimum Python versions as we don't test or support any others.
@@ -61,6 +64,7 @@ class Output(object):
PASSED = COLOR.color(COLOR.GREEN, 'PASSED')
FAILED = COLOR.color(COLOR.RED, 'FAILED')
WARNING = COLOR.color(COLOR.YELLOW, 'WARNING')
+ FIXUP = COLOR.color(COLOR.MAGENTA, 'FIXUP')
# How long a hook is allowed to run before we warn that it is "too slow".
_SLOW_HOOK_DURATION = datetime.timedelta(seconds=30)
@@ -72,70 +76,91 @@ class Output(object):
project_name: name of project.
"""
self.project_name = project_name
+ self.hooks = None
self.num_hooks = None
- self.hook_index = 0
+ self.num_commits = None
+ self.commit_index = 0
self.success = True
self.start_time = datetime.datetime.now()
self.hook_start_time = None
- self._curr_hook_name = None
+ # Cache number of invisible characters in our banner.
+ self._banner_esc_chars = len(self.COLOR.color(self.COLOR.YELLOW, ''))
- def set_num_hooks(self, num_hooks):
- """Keep track of how many hooks we'll be running.
+ def set_num_commits(self, num_commits: int) -> None:
+ """Keep track of how many commits we'll be running.
Args:
- num_hooks: number of hooks to be run.
+ num_commits: Number of commits to be run.
"""
- self.num_hooks = num_hooks
+ self.num_commits = num_commits
+ self.commit_index = 1
- def commit_start(self, commit, commit_summary):
+ def commit_start(self, hooks, commit, commit_summary):
"""Emit status for new commit.
Args:
+ hooks: All the hooks to be run for this commit.
commit: commit hash.
commit_summary: commit summary.
"""
- status_line = f'[{self.COMMIT} {commit[0:12]}] {commit_summary}'
+ status_line = (
+ f'[{self.COMMIT} '
+ f'{self.commit_index}/{self.num_commits} '
+ f'{commit[0:12]}] {commit_summary}'
+ )
rh.terminal.print_status_line(status_line, print_newline=True)
- self.hook_index = 1
-
- def hook_start(self, hook_name):
- """Emit status before the start of a hook.
-
- Args:
- hook_name: name of the hook.
- """
- self._curr_hook_name = hook_name
- self.hook_start_time = datetime.datetime.now()
- status_line = (f'[{self.RUNNING} {self.hook_index}/{self.num_hooks}] '
- f'{hook_name}')
- self.hook_index += 1
+ self.commit_index += 1
+
+ # Initialize the pending hooks line too.
+ self.hooks = set(hooks)
+ self.num_hooks = len(hooks)
+ self.hook_banner()
+
+ def hook_banner(self):
+ """Display the banner for current set of hooks."""
+ pending = ', '.join(x.name for x in self.hooks)
+ status_line = (
+ f'[{self.RUNNING} '
+ f'{self.num_hooks - len(self.hooks)}/{self.num_hooks}] '
+ f'{pending}'
+ )
+ if self._banner_esc_chars and sys.stderr.isatty():
+ cols = os.get_terminal_size(sys.stderr.fileno()).columns
+ status_line = status_line[0:cols + self._banner_esc_chars]
rh.terminal.print_status_line(status_line)
- def hook_finish(self):
+ def hook_finish(self, hook, duration):
"""Finish processing any per-hook state."""
- duration = datetime.datetime.now() - self.hook_start_time
+ self.hooks.remove(hook)
if duration >= self._SLOW_HOOK_DURATION:
d = rh.utils.timedelta_str(duration)
self.hook_warning(
+ hook,
f'This hook took {d} to finish which is fairly slow for '
'developers.\nPlease consider moving the check to the '
'server/CI system instead.')
- def hook_error(self, error):
+ # Show any hooks still pending.
+ if self.hooks:
+ self.hook_banner()
+
+ def hook_error(self, hook, error):
"""Print an error for a single hook.
Args:
+ hook: The hook that generated the output.
error: error string.
"""
- self.error(self._curr_hook_name, error)
+ self.error(f'{hook.name} hook', error)
- def hook_warning(self, warning):
+ def hook_warning(self, hook, warning):
"""Print a warning for a single hook.
Args:
+ hook: The hook that generated the output.
warning: warning string.
"""
- status_line = f'[{self.WARNING}] {self._curr_hook_name}'
+ status_line = f'[{self.WARNING}] {hook.name}'
rh.terminal.print_status_line(status_line, print_newline=True)
print(warning, file=sys.stderr)
@@ -151,6 +176,21 @@ class Output(object):
print(error, file=sys.stderr)
self.success = False
+ def hook_fixups(
+ self,
+ project_results: rh.results.ProjectResults,
+ hook_results: List[rh.results.HookResult],
+ ) -> None:
+ """Display summary of possible fixups for a single hook."""
+ for result in (x for x in hook_results if x.fixup_cmd):
+ cmd = result.fixup_cmd + list(result.files)
+ for line in (
+ f'[{self.FIXUP}] {result.hook} has automated fixups available',
+ f' cd {rh.shell.quote(project_results.workdir)} && \\',
+ f' {rh.shell.cmd_to_str(cmd)}',
+ ):
+ rh.terminal.print_status_line(line, print_newline=True)
+
def finish(self):
"""Print summary for all the hooks."""
header = self.PASSED if self.success else self.FAILED
@@ -182,10 +222,10 @@ def _process_hook_results(results):
error_ret = ''
warning_ret = ''
for result in results:
- if result:
+ if result or result.is_warning():
ret = ''
if result.files:
- ret += f' FILES: {result.files}'
+ ret += f' FILES: {rh.shell.cmd_to_str(result.files)}\n'
lines = result.error.splitlines()
ret += '\n'.join(f' {x}' for x in lines)
if result.is_warning():
@@ -224,44 +264,86 @@ def _get_project_config(from_git=False):
return rh.config.PreUploadSettings(paths=paths, global_paths=global_paths)
-def _attempt_fixes(fixup_func_list, commit_list):
- """Attempts to run |fixup_func_list| given |commit_list|."""
- if len(fixup_func_list) != 1:
- # Only single fixes will be attempted, since various fixes might
- # interact with each other.
- return
-
- hook_name, commit, fixup_func = fixup_func_list[0]
-
- if commit != commit_list[0]:
- # If the commit is not at the top of the stack, git operations might be
- # needed and might leave the working directory in a tricky state if the
- # fix is attempted to run automatically (e.g. it might require manual
- # merge conflict resolution). Refuse to run the fix in those cases.
+def _attempt_fixes(projects_results: List[rh.results.ProjectResults]) -> None:
+ """Attempts to fix fixable results."""
+ # Filter out any result that has a fixup.
+ fixups = []
+ for project_results in projects_results:
+ fixups.extend((project_results.workdir, x)
+ for x in project_results.fixups)
+ if not fixups:
return
- prompt = (f'An automatic fix can be attempted for the "{hook_name}" hook. '
- 'Do you want to run it?')
- if not rh.terminal.boolean_prompt(prompt):
- return
-
- result = fixup_func()
- if result:
- print(f'Attempt to fix "{hook_name}" for commit "{commit}" failed: '
- f'{result}',
- file=sys.stderr)
+ if len(fixups) > 1:
+ banner = f'Multiple fixups ({len(fixups)}) are available.'
else:
- print('Fix successfully applied. Amend the current commit before '
- 'attempting to upload again.\n', file=sys.stderr)
-
-
-def _run_project_hooks_in_cwd(project_name, proj_dir, output, from_git=False, commit_list=None):
+ banner = 'Automated fixups are available.'
+ print(Output.COLOR.color(Output.COLOR.MAGENTA, banner), file=sys.stderr)
+
+ # If there's more than one fixup available, ask if they want to blindly run
+ # them all, or prompt for them one-by-one.
+ mode = 'some'
+ if len(fixups) > 1:
+ while True:
+ response = rh.terminal.str_prompt(
+ 'What would you like to do',
+ ('Run (A)ll', 'Run (S)ome', '(D)ry-run', '(N)othing [default]'))
+ if not response:
+ print('', file=sys.stderr)
+ return
+ if response.startswith('a') or response.startswith('y'):
+ mode = 'all'
+ break
+ elif response.startswith('s'):
+ mode = 'some'
+ break
+ elif response.startswith('d'):
+ mode = 'dry-run'
+ break
+ elif response.startswith('n'):
+ print('', file=sys.stderr)
+ return
+
+ # Walk all the fixups and run them one-by-one.
+ for workdir, result in fixups:
+ if mode == 'some':
+ if not rh.terminal.boolean_prompt(
+ f'Run {result.hook} fixup for {result.commit}'
+ ):
+ continue
+
+ cmd = tuple(result.fixup_cmd) + tuple(result.files)
+ print(
+ f'\n[{Output.RUNNING}] cd {rh.shell.quote(workdir)} && '
+ f'{rh.shell.cmd_to_str(cmd)}', file=sys.stderr)
+ if mode == 'dry-run':
+ continue
+
+ cmd_result = rh.utils.run(cmd, cwd=workdir, check=False)
+ if cmd_result.returncode:
+ print(f'[{Output.WARNING}] command exited {cmd_result.returncode}',
+ file=sys.stderr)
+ else:
+ print(f'[{Output.PASSED}] great success', file=sys.stderr)
+
+ print(f'\n[{Output.FIXUP}] Please amend & rebase your tree before '
+ 'attempting to upload again.\n', file=sys.stderr)
+
+def _run_project_hooks_in_cwd(
+ project_name: str,
+ proj_dir: str,
+ output: Output,
+ jobs: Optional[int] = None,
+ from_git: bool = False,
+ commit_list: Optional[List[str]] = None,
+) -> rh.results.ProjectResults:
"""Run the project-specific hooks in the cwd.
Args:
project_name: The name of this project.
proj_dir: The directory for this project (for passing on in metadata).
output: Helper for summarizing output/errors to the user.
+ jobs: How many hooks to run in parallel.
from_git: If true, we are called from git directly and repo should not be
used.
commit_list: A list of commits to run hooks against. If None or empty
@@ -269,20 +351,21 @@ def _run_project_hooks_in_cwd(project_name, proj_dir, output, from_git=False, co
uploaded.
Returns:
- False if any errors were found, else True.
+ All the results for this project.
"""
+ ret = rh.results.ProjectResults(project_name, proj_dir)
+
try:
config = _get_project_config(from_git)
except rh.config.ValidationError as e:
output.error('Loading config files', str(e))
- return False
+ ret.internal_failure = True
+ return ret
# If the repo has no pre-upload hooks enabled, then just return.
hooks = list(config.callable_hooks())
if not hooks:
- return True
-
- output.set_num_hooks(len(hooks))
+ return ret
# Set up the environment like repo would with the forall command.
try:
@@ -291,11 +374,17 @@ def _run_project_hooks_in_cwd(project_name, proj_dir, output, from_git=False, co
except rh.utils.CalledProcessError as e:
output.error('Upstream remote/tracking branch lookup',
f'{e}\nDid you run repo start? Is your HEAD detached?')
- return False
+ ret.internal_failure = True
+ return ret
- project = rh.Project(name=project_name, dir=proj_dir, remote=remote)
+ project = rh.Project(name=project_name, dir=proj_dir)
rel_proj_dir = os.path.relpath(proj_dir, rh.git.find_repo_root())
+ # Filter out the hooks to process.
+ hooks = [x for x in hooks if rel_proj_dir not in x.scope]
+ if not hooks:
+ return ret
+
os.environ.update({
'REPO_LREV': rh.git.get_commit_for_ref(upstream_branch),
'REPO_PATH': rel_proj_dir,
@@ -307,51 +396,61 @@ def _run_project_hooks_in_cwd(project_name, proj_dir, output, from_git=False, co
if not commit_list:
commit_list = rh.git.get_commits(
ignore_merged_commits=config.ignore_merged_commits)
-
- ret = True
- fixup_func_list = []
-
- for commit in commit_list:
- # Mix in some settings for our hooks.
- os.environ['PREUPLOAD_COMMIT'] = commit
- diff = rh.git.get_affected_files(commit)
- desc = rh.git.get_commit_desc(commit)
- os.environ['PREUPLOAD_COMMIT_MESSAGE'] = desc
-
- commit_summary = desc.split('\n', 1)[0]
- output.commit_start(commit=commit, commit_summary=commit_summary)
-
- for name, hook, exclusion_scope in hooks:
- output.hook_start(name)
- if rel_proj_dir in exclusion_scope:
- break
- hook_results = hook(project, commit, desc, diff)
- output.hook_finish()
- (error, warning) = _process_hook_results(hook_results)
- if error is not None or warning is not None:
- if warning is not None:
- output.hook_warning(warning)
- if error is not None:
- ret = False
- output.hook_error(error)
- for result in hook_results:
- if result.fixup_func:
- fixup_func_list.append((name, commit,
- result.fixup_func))
-
- if fixup_func_list:
- _attempt_fixes(fixup_func_list, commit_list)
+ output.set_num_commits(len(commit_list))
+
+ def _run_hook(hook, project, commit, desc, diff):
+ """Run a hook, gather stats, and process its results."""
+ start = datetime.datetime.now()
+ results = hook.hook(project, commit, desc, diff)
+ (error, warning) = _process_hook_results(results)
+ duration = datetime.datetime.now() - start
+ return (hook, results, error, warning, duration)
+
+ with concurrent.futures.ThreadPoolExecutor(max_workers=jobs) as executor:
+ for commit in commit_list:
+ # Mix in some settings for our hooks.
+ os.environ['PREUPLOAD_COMMIT'] = commit
+ diff = rh.git.get_affected_files(commit)
+ desc = rh.git.get_commit_desc(commit)
+ os.environ['PREUPLOAD_COMMIT_MESSAGE'] = desc
+
+ commit_summary = desc.split('\n', 1)[0]
+ output.commit_start(hooks, commit, commit_summary)
+
+ futures = (
+ executor.submit(_run_hook, hook, project, commit, desc, diff)
+ for hook in hooks
+ )
+ future_results = (
+ x.result() for x in concurrent.futures.as_completed(futures)
+ )
+ for hook, hook_results, error, warning, duration in future_results:
+ ret.add_results(hook_results)
+ if error is not None or warning is not None:
+ if warning is not None:
+ output.hook_warning(hook, warning)
+ if error is not None:
+ output.hook_error(hook, error)
+ output.hook_fixups(ret, hook_results)
+ output.hook_finish(hook, duration)
return ret
-def _run_project_hooks(project_name, proj_dir=None, from_git=False, commit_list=None):
+def _run_project_hooks(
+ project_name: str,
+ proj_dir: Optional[str] = None,
+ jobs: Optional[int] = None,
+ from_git: bool = False,
+ commit_list: Optional[List[str]] = None,
+) -> rh.results.ProjectResults:
"""Run the project-specific hooks in |proj_dir|.
Args:
project_name: The name of project to run hooks for.
proj_dir: If non-None, this is the directory the project is in. If None,
we'll ask repo.
+ jobs: How many hooks to run in parallel.
from_git: If true, we are called from git directly and repo should not be
used.
commit_list: A list of commits to run hooks against. If None or empty
@@ -359,7 +458,7 @@ def _run_project_hooks(project_name, proj_dir=None, from_git=False, commit_list=
uploaded.
Returns:
- False if any errors were found, else True.
+ All the results for this project.
"""
output = Output(project_name)
@@ -383,14 +482,56 @@ def _run_project_hooks(project_name, proj_dir=None, from_git=False, commit_list=
try:
# Hooks assume they are run from the root of the project.
os.chdir(proj_dir)
- return _run_project_hooks_in_cwd(project_name, proj_dir, output,
- from_git=from_git,
- commit_list=commit_list)
+ return _run_project_hooks_in_cwd(
+ project_name, proj_dir, output, jobs=jobs, from_git=from_git,
+ commit_list=commit_list)
finally:
output.finish()
os.chdir(pwd)
+def _run_projects_hooks(
+ project_list: List[str],
+ worktree_list: List[Optional[str]],
+ jobs: Optional[int] = None,
+ from_git: bool = False,
+ commit_list: Optional[List[str]] = None,
+) -> bool:
+ """Run all the hooks
+
+ Args:
+ project_list: List of project names.
+ worktree_list: List of project checkouts.
+ jobs: How many hooks to run in parallel.
+ from_git: If true, we are called from git directly and repo should not be
+ used.
+ commit_list: A list of commits to run hooks against. If None or empty
+ list then we'll automatically get the list of commits that would be
+ uploaded.
+
+ Returns:
+ True if everything passed, else False.
+ """
+ results = []
+ for project, worktree in zip(project_list, worktree_list):
+ result = _run_project_hooks(
+ project,
+ proj_dir=worktree,
+ jobs=jobs,
+ from_git=from_git,
+ commit_list=commit_list,
+ )
+ results.append(result)
+ if result:
+ # If a repo had failures, add a blank line to help break up the
+ # output. If there were no failures, then the output should be
+ # very minimal, so we don't add it then.
+ print('', file=sys.stderr)
+
+ _attempt_fixes(results)
+ return not any(results)
+
+
def main(project_list, worktree_list=None, **_kwargs):
"""Main function invoked directly by repo.
@@ -407,22 +548,13 @@ def main(project_list, worktree_list=None, **_kwargs):
the directories automatically.
kwargs: Leave this here for forward-compatibility.
"""
- found_error = False
if not worktree_list:
worktree_list = [None] * len(project_list)
- for project, worktree in zip(project_list, worktree_list):
- if not _run_project_hooks(project, proj_dir=worktree):
- found_error = True
- # If a repo had failures, add a blank line to help break up the
- # output. If there were no failures, then the output should be
- # very minimal, so we don't add it then.
- print('', file=sys.stderr)
-
- if found_error:
+ if not _run_projects_hooks(project_list, worktree_list):
color = rh.terminal.Color()
print(color.color(color.RED, 'FATAL') +
': Preupload failed due to above error(s).\n'
- f'For more info, please see:\n{REPOHOOKS_URL}',
+ f'For more info, see: {REPOHOOKS_URL}',
file=sys.stderr)
sys.exit(1)
@@ -438,10 +570,11 @@ def _identify_project(path, from_git=False):
cmd = ['git', 'rev-parse', '--show-toplevel']
project_path = rh.utils.run(cmd, capture_output=True).stdout.strip()
cmd = ['git', 'rev-parse', '--show-superproject-working-tree']
- superproject_path = rh.utils.run(cmd, capture_output=True).stdout.strip()
+ superproject_path = rh.utils.run(
+ cmd, capture_output=True).stdout.strip()
module_path = project_path[len(superproject_path) + 1:]
cmd = ['git', 'config', '-f', '.gitmodules',
- '--name-only', '--get-regexp', '^submodule\..*\.path$',
+ '--name-only', '--get-regexp', r'^submodule\..*\.path$',
f"^{module_path}$"]
module_name = rh.utils.run(cmd, cwd=superproject_path,
capture_output=True).stdout.strip()
@@ -474,6 +607,11 @@ def direct_main(argv):
'hooks get run, since some hooks are project-specific.'
'If not specified, `repo` will be used to figure this '
'out based on the dir.')
+ parser.add_argument('-j', '--jobs', type=int,
+ help='Run up to this many hooks in parallel. Setting '
+ 'to 1 forces serial execution, and the default '
+ 'automatically chooses an appropriate number for the '
+ 'current system.')
parser.add_argument('commits', nargs='*',
help='Check specific commits')
opts = parser.parse_args(argv)
@@ -498,9 +636,13 @@ def direct_main(argv):
if not opts.project:
parser.error(f"Couldn't identify the project of {opts.dir}")
- if _run_project_hooks(opts.project, proj_dir=opts.dir, from_git=opts.git,
- commit_list=opts.commits):
- return 0
+ try:
+ if _run_projects_hooks([opts.project], [opts.dir], jobs=opts.jobs,
+ from_git=opts.git, commit_list=opts.commits):
+ return 0
+ except KeyboardInterrupt:
+ print('Aborting execution early due to user interrupt', file=sys.stderr)
+ return 128 + signal.SIGINT
return 1