blob: 337c2627ba12a1a16c1af72103d679d8e93598bc [file] [log] [blame]
Remy Bohmer16c13282020-09-10 10:38:04 +02001# Copyright (C) 2008 The Android Open Source Project
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14
Remy Bohmer16c13282020-09-10 10:38:04 +020015import os
16import re
17import sys
18import traceback
Mike Frysingeracf63b22019-06-13 02:24:21 -040019import urllib.parse
Remy Bohmer16c13282020-09-10 10:38:04 +020020
21from error import HookError
22from git_refs import HEAD
23
Remy Bohmer7f7acfe2020-08-01 18:36:44 +020024
Remy Bohmer16c13282020-09-10 10:38:04 +020025class RepoHook(object):
Gavin Makea2e3302023-03-11 06:46:20 +000026 """A RepoHook contains information about a script to run as a hook.
Remy Bohmer16c13282020-09-10 10:38:04 +020027
Gavin Makea2e3302023-03-11 06:46:20 +000028 Hooks are used to run a python script before running an upload (for
29 instance, to run presubmit checks). Eventually, we may have hooks for other
30 actions.
Remy Bohmer16c13282020-09-10 10:38:04 +020031
Gavin Makea2e3302023-03-11 06:46:20 +000032 This shouldn't be confused with files in the 'repo/hooks' directory. Those
33 files are copied into each '.git/hooks' folder for each project. Repo-level
34 hooks are associated instead with repo actions.
Remy Bohmer16c13282020-09-10 10:38:04 +020035
Gavin Makea2e3302023-03-11 06:46:20 +000036 Hooks are always python. When a hook is run, we will load the hook into the
37 interpreter and execute its main() function.
Remy Bohmer7f7acfe2020-08-01 18:36:44 +020038
Gavin Makea2e3302023-03-11 06:46:20 +000039 Combinations of hook option flags:
40 - no-verify=False, verify=False (DEFAULT):
41 If stdout is a tty, can prompt about running hooks if needed.
42 If user denies running hooks, the action is cancelled. If stdout is
43 not a tty and we would need to prompt about hooks, action is
44 cancelled.
45 - no-verify=False, verify=True:
46 Always run hooks with no prompt.
47 - no-verify=True, verify=False:
48 Never run hooks, but run action anyway (AKA bypass hooks).
49 - no-verify=True, verify=True:
50 Invalid
Remy Bohmer16c13282020-09-10 10:38:04 +020051 """
Remy Bohmer16c13282020-09-10 10:38:04 +020052
Gavin Makea2e3302023-03-11 06:46:20 +000053 def __init__(
54 self,
55 hook_type,
56 hooks_project,
57 repo_topdir,
58 manifest_url,
59 bypass_hooks=False,
60 allow_all_hooks=False,
61 ignore_hooks=False,
62 abort_if_user_denies=False,
63 ):
64 """RepoHook constructor.
Remy Bohmer16c13282020-09-10 10:38:04 +020065
Gavin Makea2e3302023-03-11 06:46:20 +000066 Params:
67 hook_type: A string representing the type of hook. This is also used
68 to figure out the name of the file containing the hook. For
69 example: 'pre-upload'.
70 hooks_project: The project containing the repo hooks.
71 If you have a manifest, this is manifest.repo_hooks_project.
72 OK if this is None, which will make the hook a no-op.
73 repo_topdir: The top directory of the repo client checkout.
74 This is the one containing the .repo directory. Scripts will
75 run with CWD as this directory.
76 If you have a manifest, this is manifest.topdir.
77 manifest_url: The URL to the manifest git repo.
78 bypass_hooks: If True, then 'Do not run the hook'.
79 allow_all_hooks: If True, then 'Run the hook without prompting'.
80 ignore_hooks: If True, then 'Do not abort action if hooks fail'.
81 abort_if_user_denies: If True, we'll abort running the hook if the
82 user doesn't allow us to run the hook.
83 """
84 self._hook_type = hook_type
85 self._hooks_project = hooks_project
86 self._repo_topdir = repo_topdir
87 self._manifest_url = manifest_url
88 self._bypass_hooks = bypass_hooks
89 self._allow_all_hooks = allow_all_hooks
90 self._ignore_hooks = ignore_hooks
91 self._abort_if_user_denies = abort_if_user_denies
Remy Bohmer16c13282020-09-10 10:38:04 +020092
Gavin Makea2e3302023-03-11 06:46:20 +000093 # Store the full path to the script for convenience.
94 if self._hooks_project:
95 self._script_fullpath = os.path.join(
96 self._hooks_project.worktree, self._hook_type + ".py"
97 )
98 else:
99 self._script_fullpath = None
Remy Bohmer16c13282020-09-10 10:38:04 +0200100
Gavin Makea2e3302023-03-11 06:46:20 +0000101 def _GetHash(self):
102 """Return a hash of the contents of the hooks directory.
Remy Bohmer16c13282020-09-10 10:38:04 +0200103
Gavin Makea2e3302023-03-11 06:46:20 +0000104 We'll just use git to do this. This hash has the property that if
105 anything changes in the directory we will return a different has.
Remy Bohmer16c13282020-09-10 10:38:04 +0200106
Gavin Makea2e3302023-03-11 06:46:20 +0000107 SECURITY CONSIDERATION:
108 This hash only represents the contents of files in the hook
109 directory, not any other files imported or called by hooks. Changes
110 to imported files can change the script behavior without affecting
111 the hash.
Remy Bohmer16c13282020-09-10 10:38:04 +0200112
Gavin Makea2e3302023-03-11 06:46:20 +0000113 Returns:
114 A string representing the hash. This will always be ASCII so that
115 it can be printed to the user easily.
116 """
117 assert self._hooks_project, "Must have hooks to calculate their hash."
Remy Bohmer16c13282020-09-10 10:38:04 +0200118
Gavin Makea2e3302023-03-11 06:46:20 +0000119 # We will use the work_git object rather than just calling
120 # GetRevisionId(). That gives us a hash of the latest checked in version
121 # of the files that the user will actually be executing. Specifically,
122 # GetRevisionId() doesn't appear to change even if a user checks out a
123 # different version of the hooks repo (via git checkout) nor if a user
124 # commits their own revs.
125 #
126 # NOTE: Local (non-committed) changes will not be factored into this
127 # hash. I think this is OK, since we're really only worried about
128 # warning the user about upstream changes.
129 return self._hooks_project.work_git.rev_parse(HEAD)
Remy Bohmer16c13282020-09-10 10:38:04 +0200130
Gavin Makea2e3302023-03-11 06:46:20 +0000131 def _GetMustVerb(self):
132 """Return 'must' if the hook is required; 'should' if not."""
133 if self._abort_if_user_denies:
134 return "must"
135 else:
136 return "should"
Remy Bohmer16c13282020-09-10 10:38:04 +0200137
Gavin Makea2e3302023-03-11 06:46:20 +0000138 def _CheckForHookApproval(self):
139 """Check to see whether this hook has been approved.
Remy Bohmer16c13282020-09-10 10:38:04 +0200140
Gavin Makea2e3302023-03-11 06:46:20 +0000141 We'll accept approval of manifest URLs if they're using secure
142 transports. This way the user can say they trust the manifest hoster.
143 For insecure hosts, we fall back to checking the hash of the hooks repo.
Remy Bohmer16c13282020-09-10 10:38:04 +0200144
Gavin Makea2e3302023-03-11 06:46:20 +0000145 Note that we ask permission for each individual hook even though we use
146 the hash of all hooks when detecting changes. We'd like the user to be
147 able to approve / deny each hook individually. We only use the hash of
148 all hooks because there is no other easy way to detect changes to local
149 imports.
Remy Bohmer16c13282020-09-10 10:38:04 +0200150
Gavin Makea2e3302023-03-11 06:46:20 +0000151 Returns:
152 True if this hook is approved to run; False otherwise.
Remy Bohmer16c13282020-09-10 10:38:04 +0200153
Gavin Makea2e3302023-03-11 06:46:20 +0000154 Raises:
155 HookError: Raised if the user doesn't approve and
156 abort_if_user_denies was passed to the consturctor.
157 """
158 if self._ManifestUrlHasSecureScheme():
159 return self._CheckForHookApprovalManifest()
160 else:
161 return self._CheckForHookApprovalHash()
Remy Bohmer16c13282020-09-10 10:38:04 +0200162
Gavin Makea2e3302023-03-11 06:46:20 +0000163 def _CheckForHookApprovalHelper(
164 self, subkey, new_val, main_prompt, changed_prompt
165 ):
166 """Check for approval for a particular attribute and hook.
Remy Bohmer16c13282020-09-10 10:38:04 +0200167
Gavin Makea2e3302023-03-11 06:46:20 +0000168 Args:
169 subkey: The git config key under [repo.hooks.<hook_type>] to store
170 the last approved string.
171 new_val: The new value to compare against the last approved one.
172 main_prompt: Message to display to the user to ask for approval.
173 changed_prompt: Message explaining why we're re-asking for approval.
Remy Bohmer16c13282020-09-10 10:38:04 +0200174
Gavin Makea2e3302023-03-11 06:46:20 +0000175 Returns:
176 True if this hook is approved to run; False otherwise.
Remy Bohmer16c13282020-09-10 10:38:04 +0200177
Gavin Makea2e3302023-03-11 06:46:20 +0000178 Raises:
179 HookError: Raised if the user doesn't approve and
180 abort_if_user_denies was passed to the consturctor.
181 """
182 hooks_config = self._hooks_project.config
183 git_approval_key = "repo.hooks.%s.%s" % (self._hook_type, subkey)
Remy Bohmer16c13282020-09-10 10:38:04 +0200184
Gavin Makea2e3302023-03-11 06:46:20 +0000185 # Get the last value that the user approved for this hook; may be None.
186 old_val = hooks_config.GetString(git_approval_key)
Remy Bohmer16c13282020-09-10 10:38:04 +0200187
Gavin Makea2e3302023-03-11 06:46:20 +0000188 if old_val is not None:
189 # User previously approved hook and asked not to be prompted again.
190 if new_val == old_val:
191 # Approval matched. We're done.
192 return True
193 else:
194 # Give the user a reason why we're prompting, since they last
195 # told us to "never ask again".
196 prompt = "WARNING: %s\n\n" % (changed_prompt,)
197 else:
198 prompt = ""
Remy Bohmer16c13282020-09-10 10:38:04 +0200199
Gavin Makea2e3302023-03-11 06:46:20 +0000200 # Prompt the user if we're not on a tty; on a tty we'll assume "no".
201 if sys.stdout.isatty():
202 prompt += main_prompt + " (yes/always/NO)? "
203 response = input(prompt).lower()
204 print()
Remy Bohmer16c13282020-09-10 10:38:04 +0200205
Gavin Makea2e3302023-03-11 06:46:20 +0000206 # User is doing a one-time approval.
207 if response in ("y", "yes"):
208 return True
209 elif response == "always":
210 hooks_config.SetString(git_approval_key, new_val)
211 return True
Remy Bohmer16c13282020-09-10 10:38:04 +0200212
Gavin Makea2e3302023-03-11 06:46:20 +0000213 # For anything else, we'll assume no approval.
214 if self._abort_if_user_denies:
215 raise HookError(
216 "You must allow the %s hook or use --no-verify."
217 % self._hook_type
218 )
Remy Bohmer16c13282020-09-10 10:38:04 +0200219
Gavin Makea2e3302023-03-11 06:46:20 +0000220 return False
Remy Bohmer16c13282020-09-10 10:38:04 +0200221
Gavin Makea2e3302023-03-11 06:46:20 +0000222 def _ManifestUrlHasSecureScheme(self):
223 """Check if the URI for the manifest is a secure transport."""
224 secure_schemes = (
225 "file",
226 "https",
227 "ssh",
228 "persistent-https",
229 "sso",
230 "rpc",
231 )
232 parse_results = urllib.parse.urlparse(self._manifest_url)
233 return parse_results.scheme in secure_schemes
Remy Bohmer16c13282020-09-10 10:38:04 +0200234
Gavin Makea2e3302023-03-11 06:46:20 +0000235 def _CheckForHookApprovalManifest(self):
236 """Check whether the user has approved this manifest host.
Remy Bohmer16c13282020-09-10 10:38:04 +0200237
Gavin Makea2e3302023-03-11 06:46:20 +0000238 Returns:
239 True if this hook is approved to run; False otherwise.
240 """
241 return self._CheckForHookApprovalHelper(
242 "approvedmanifest",
243 self._manifest_url,
244 "Run hook scripts from %s" % (self._manifest_url,),
245 "Manifest URL has changed since %s was allowed."
246 % (self._hook_type,),
247 )
Remy Bohmer16c13282020-09-10 10:38:04 +0200248
Gavin Makea2e3302023-03-11 06:46:20 +0000249 def _CheckForHookApprovalHash(self):
250 """Check whether the user has approved the hooks repo.
Remy Bohmer16c13282020-09-10 10:38:04 +0200251
Gavin Makea2e3302023-03-11 06:46:20 +0000252 Returns:
253 True if this hook is approved to run; False otherwise.
254 """
255 prompt = (
256 "Repo %s run the script:\n"
257 " %s\n"
258 "\n"
259 "Do you want to allow this script to run"
260 )
261 return self._CheckForHookApprovalHelper(
262 "approvedhash",
263 self._GetHash(),
264 prompt % (self._GetMustVerb(), self._script_fullpath),
265 "Scripts have changed since %s was allowed." % (self._hook_type,),
266 )
Remy Bohmer16c13282020-09-10 10:38:04 +0200267
Gavin Makea2e3302023-03-11 06:46:20 +0000268 @staticmethod
269 def _ExtractInterpFromShebang(data):
270 """Extract the interpreter used in the shebang.
Remy Bohmer16c13282020-09-10 10:38:04 +0200271
Gavin Makea2e3302023-03-11 06:46:20 +0000272 Try to locate the interpreter the script is using (ignoring `env`).
Remy Bohmer16c13282020-09-10 10:38:04 +0200273
Gavin Makea2e3302023-03-11 06:46:20 +0000274 Args:
275 data: The file content of the script.
Remy Bohmer16c13282020-09-10 10:38:04 +0200276
Gavin Makea2e3302023-03-11 06:46:20 +0000277 Returns:
278 The basename of the main script interpreter, or None if a shebang is
279 not used or could not be parsed out.
280 """
281 firstline = data.splitlines()[:1]
282 if not firstline:
283 return None
Remy Bohmer16c13282020-09-10 10:38:04 +0200284
Gavin Makea2e3302023-03-11 06:46:20 +0000285 # The format here can be tricky.
286 shebang = firstline[0].strip()
287 m = re.match(r"^#!\s*([^\s]+)(?:\s+([^\s]+))?", shebang)
288 if not m:
289 return None
Remy Bohmer16c13282020-09-10 10:38:04 +0200290
Gavin Makea2e3302023-03-11 06:46:20 +0000291 # If the using `env`, find the target program.
292 interp = m.group(1)
293 if os.path.basename(interp) == "env":
294 interp = m.group(2)
Remy Bohmer16c13282020-09-10 10:38:04 +0200295
Gavin Makea2e3302023-03-11 06:46:20 +0000296 return interp
Remy Bohmer16c13282020-09-10 10:38:04 +0200297
Gavin Makea2e3302023-03-11 06:46:20 +0000298 def _ExecuteHookViaImport(self, data, context, **kwargs):
299 """Execute the hook code in |data| directly.
Remy Bohmer16c13282020-09-10 10:38:04 +0200300
Gavin Makea2e3302023-03-11 06:46:20 +0000301 Args:
302 data: The code of the hook to execute.
303 context: Basic Python context to execute the hook inside.
304 kwargs: Arbitrary arguments to pass to the hook script.
Remy Bohmer16c13282020-09-10 10:38:04 +0200305
Gavin Makea2e3302023-03-11 06:46:20 +0000306 Raises:
307 HookError: When the hooks failed for any reason.
308 """
309 # Exec, storing global context in the context dict. We catch exceptions
310 # and convert to a HookError w/ just the failing traceback.
Remy Bohmer16c13282020-09-10 10:38:04 +0200311 try:
Gavin Makea2e3302023-03-11 06:46:20 +0000312 exec(compile(data, self._script_fullpath, "exec"), context)
313 except Exception:
314 raise HookError(
315 "%s\nFailed to import %s hook; see traceback above."
316 % (traceback.format_exc(), self._hook_type)
317 )
318
319 # Running the script should have defined a main() function.
320 if "main" not in context:
321 raise HookError('Missing main() in: "%s"' % self._script_fullpath)
322
323 # Call the main function in the hook. If the hook should cause the
324 # build to fail, it will raise an Exception. We'll catch that convert
325 # to a HookError w/ just the failing traceback.
326 try:
327 context["main"](**kwargs)
328 except Exception:
329 raise HookError(
330 "%s\nFailed to run main() for %s hook; see traceback "
331 "above." % (traceback.format_exc(), self._hook_type)
332 )
333
334 def _ExecuteHook(self, **kwargs):
335 """Actually execute the given hook.
336
337 This will run the hook's 'main' function in our python interpreter.
338
339 Args:
340 kwargs: Keyword arguments to pass to the hook. These are often
341 specific to the hook type. For instance, pre-upload hooks will
342 contain a project_list.
343 """
344 # Keep sys.path and CWD stashed away so that we can always restore them
345 # upon function exit.
346 orig_path = os.getcwd()
347 orig_syspath = sys.path
348
349 try:
350 # Always run hooks with CWD as topdir.
351 os.chdir(self._repo_topdir)
352
353 # Put the hook dir as the first item of sys.path so hooks can do
354 # relative imports. We want to replace the repo dir as [0] so
355 # hooks can't import repo files.
356 sys.path = [os.path.dirname(self._script_fullpath)] + sys.path[1:]
357
358 # Initial global context for the hook to run within.
359 context = {"__file__": self._script_fullpath}
360
361 # Add 'hook_should_take_kwargs' to the arguments to be passed to
362 # main. We don't actually want hooks to define their main with this
363 # argument--it's there to remind them that their hook should always
364 # take **kwargs.
365 # For instance, a pre-upload hook should be defined like:
366 # def main(project_list, **kwargs):
367 #
368 # This allows us to later expand the API without breaking old hooks.
369 kwargs = kwargs.copy()
370 kwargs["hook_should_take_kwargs"] = True
371
372 # See what version of python the hook has been written against.
373 data = open(self._script_fullpath).read()
374 interp = self._ExtractInterpFromShebang(data)
Gavin Makea2e3302023-03-11 06:46:20 +0000375 if interp:
376 prog = os.path.basename(interp)
Mike Frysinger3b8f9532023-10-14 01:25:50 +0545377 if prog.startswith("python2"):
378 raise HookError("Python 2 is not supported")
Remy Bohmer16c13282020-09-10 10:38:04 +0200379
Gavin Makea2e3302023-03-11 06:46:20 +0000380 # Run the hook by importing directly.
Mike Frysinger3b8f9532023-10-14 01:25:50 +0545381 self._ExecuteHookViaImport(data, context, **kwargs)
Gavin Makea2e3302023-03-11 06:46:20 +0000382 finally:
383 # Restore sys.path and CWD.
384 sys.path = orig_syspath
385 os.chdir(orig_path)
Remy Bohmer7f7acfe2020-08-01 18:36:44 +0200386
Gavin Makea2e3302023-03-11 06:46:20 +0000387 def _CheckHook(self):
388 # Bail with a nice error if we can't find the hook.
389 if not os.path.isfile(self._script_fullpath):
390 raise HookError(
391 "Couldn't find repo hook: %s" % self._script_fullpath
392 )
Remy Bohmer16c13282020-09-10 10:38:04 +0200393
Gavin Makea2e3302023-03-11 06:46:20 +0000394 def Run(self, **kwargs):
395 """Run the hook.
Remy Bohmer16c13282020-09-10 10:38:04 +0200396
Gavin Makea2e3302023-03-11 06:46:20 +0000397 If the hook doesn't exist (because there is no hooks project or because
398 this particular hook is not enabled), this is a no-op.
Remy Bohmer16c13282020-09-10 10:38:04 +0200399
Gavin Makea2e3302023-03-11 06:46:20 +0000400 Args:
401 user_allows_all_hooks: If True, we will never prompt about running
402 the hook--we'll just assume it's OK to run it.
403 kwargs: Keyword arguments to pass to the hook. These are often
404 specific to the hook type. For instance, pre-upload hooks will
405 contain a project_list.
Remy Bohmer16c13282020-09-10 10:38:04 +0200406
Gavin Makea2e3302023-03-11 06:46:20 +0000407 Returns:
408 True: On success or ignore hooks by user-request
409 False: The hook failed. The caller should respond with aborting the
410 action. Some examples in which False is returned:
411 * Finding the hook failed while it was enabled, or
412 * the user declined to run a required hook (from
413 _CheckForHookApproval)
414 In all these cases the user did not pass the proper arguments to
415 ignore the result through the option combinations as listed in
416 AddHookOptionGroup().
417 """
418 # Do not do anything in case bypass_hooks is set, or
419 # no-op if there is no hooks project or if hook is disabled.
420 if (
421 self._bypass_hooks
422 or not self._hooks_project
423 or self._hook_type not in self._hooks_project.enabled_repo_hooks
424 ):
425 return True
Remy Bohmer16c13282020-09-10 10:38:04 +0200426
Gavin Makea2e3302023-03-11 06:46:20 +0000427 passed = True
428 try:
429 self._CheckHook()
Remy Bohmer16c13282020-09-10 10:38:04 +0200430
Gavin Makea2e3302023-03-11 06:46:20 +0000431 # Make sure the user is OK with running the hook.
432 if self._allow_all_hooks or self._CheckForHookApproval():
433 # Run the hook with the same version of python we're using.
434 self._ExecuteHook(**kwargs)
435 except SystemExit as e:
436 passed = False
437 print(
438 "ERROR: %s hooks exited with exit code: %s"
439 % (self._hook_type, str(e)),
440 file=sys.stderr,
441 )
442 except HookError as e:
443 passed = False
444 print("ERROR: %s" % str(e), file=sys.stderr)
Remy Bohmer7f7acfe2020-08-01 18:36:44 +0200445
Gavin Makea2e3302023-03-11 06:46:20 +0000446 if not passed and self._ignore_hooks:
447 print(
448 "\nWARNING: %s hooks failed, but continuing anyways."
449 % self._hook_type,
450 file=sys.stderr,
451 )
452 passed = True
Remy Bohmer7f7acfe2020-08-01 18:36:44 +0200453
Gavin Makea2e3302023-03-11 06:46:20 +0000454 return passed
Remy Bohmer7f7acfe2020-08-01 18:36:44 +0200455
Gavin Makea2e3302023-03-11 06:46:20 +0000456 @classmethod
457 def FromSubcmd(cls, manifest, opt, *args, **kwargs):
458 """Method to construct the repo hook class
Remy Bohmer7f7acfe2020-08-01 18:36:44 +0200459
Gavin Makea2e3302023-03-11 06:46:20 +0000460 Args:
461 manifest: The current active manifest for this command from which we
462 extract a couple of fields.
463 opt: Contains the commandline options for the action of this hook.
464 It should contain the options added by AddHookOptionGroup() in
465 which we are interested in RepoHook execution.
466 """
467 for key in ("bypass_hooks", "allow_all_hooks", "ignore_hooks"):
468 kwargs.setdefault(key, getattr(opt, key))
469 kwargs.update(
470 {
471 "hooks_project": manifest.repo_hooks_project,
472 "repo_topdir": manifest.topdir,
473 "manifest_url": manifest.manifestProject.GetRemote(
474 "origin"
475 ).url,
476 }
477 )
478 return cls(*args, **kwargs)
Remy Bohmer7f7acfe2020-08-01 18:36:44 +0200479
Gavin Makea2e3302023-03-11 06:46:20 +0000480 @staticmethod
481 def AddOptionGroup(parser, name):
482 """Help options relating to the various hooks."""
483
484 # Note that verify and no-verify are NOT opposites of each other, which
485 # is why they store to different locations. We are using them to match
486 # 'git commit' syntax.
487 group = parser.add_option_group(name + " hooks")
488 group.add_option(
489 "--no-verify",
490 dest="bypass_hooks",
491 action="store_true",
492 help="Do not run the %s hook." % name,
493 )
494 group.add_option(
495 "--verify",
496 dest="allow_all_hooks",
497 action="store_true",
498 help="Run the %s hook without prompting." % name,
499 )
500 group.add_option(
501 "--ignore-hooks",
502 action="store_true",
503 help="Do not abort if %s hooks fail." % name,
504 )