Remy Bohmer | 16c1328 | 2020-09-10 10:38:04 +0200 | [diff] [blame] | 1 | # 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 Bohmer | 16c1328 | 2020-09-10 10:38:04 +0200 | [diff] [blame] | 15 | import os |
| 16 | import re |
| 17 | import sys |
| 18 | import traceback |
Mike Frysinger | acf63b2 | 2019-06-13 02:24:21 -0400 | [diff] [blame] | 19 | import urllib.parse |
Remy Bohmer | 16c1328 | 2020-09-10 10:38:04 +0200 | [diff] [blame] | 20 | |
| 21 | from error import HookError |
| 22 | from git_refs import HEAD |
| 23 | |
Remy Bohmer | 7f7acfe | 2020-08-01 18:36:44 +0200 | [diff] [blame] | 24 | |
Remy Bohmer | 16c1328 | 2020-09-10 10:38:04 +0200 | [diff] [blame] | 25 | class RepoHook(object): |
Gavin Mak | ea2e330 | 2023-03-11 06:46:20 +0000 | [diff] [blame] | 26 | """A RepoHook contains information about a script to run as a hook. |
Remy Bohmer | 16c1328 | 2020-09-10 10:38:04 +0200 | [diff] [blame] | 27 | |
Gavin Mak | ea2e330 | 2023-03-11 06:46:20 +0000 | [diff] [blame] | 28 | 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 Bohmer | 16c1328 | 2020-09-10 10:38:04 +0200 | [diff] [blame] | 31 | |
Gavin Mak | ea2e330 | 2023-03-11 06:46:20 +0000 | [diff] [blame] | 32 | 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 Bohmer | 16c1328 | 2020-09-10 10:38:04 +0200 | [diff] [blame] | 35 | |
Gavin Mak | ea2e330 | 2023-03-11 06:46:20 +0000 | [diff] [blame] | 36 | Hooks are always python. When a hook is run, we will load the hook into the |
| 37 | interpreter and execute its main() function. |
Remy Bohmer | 7f7acfe | 2020-08-01 18:36:44 +0200 | [diff] [blame] | 38 | |
Gavin Mak | ea2e330 | 2023-03-11 06:46:20 +0000 | [diff] [blame] | 39 | 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 Bohmer | 16c1328 | 2020-09-10 10:38:04 +0200 | [diff] [blame] | 51 | """ |
Remy Bohmer | 16c1328 | 2020-09-10 10:38:04 +0200 | [diff] [blame] | 52 | |
Gavin Mak | ea2e330 | 2023-03-11 06:46:20 +0000 | [diff] [blame] | 53 | 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 Bohmer | 16c1328 | 2020-09-10 10:38:04 +0200 | [diff] [blame] | 65 | |
Gavin Mak | ea2e330 | 2023-03-11 06:46:20 +0000 | [diff] [blame] | 66 | 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 Bohmer | 16c1328 | 2020-09-10 10:38:04 +0200 | [diff] [blame] | 92 | |
Gavin Mak | ea2e330 | 2023-03-11 06:46:20 +0000 | [diff] [blame] | 93 | # 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 Bohmer | 16c1328 | 2020-09-10 10:38:04 +0200 | [diff] [blame] | 100 | |
Gavin Mak | ea2e330 | 2023-03-11 06:46:20 +0000 | [diff] [blame] | 101 | def _GetHash(self): |
| 102 | """Return a hash of the contents of the hooks directory. |
Remy Bohmer | 16c1328 | 2020-09-10 10:38:04 +0200 | [diff] [blame] | 103 | |
Gavin Mak | ea2e330 | 2023-03-11 06:46:20 +0000 | [diff] [blame] | 104 | 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 Bohmer | 16c1328 | 2020-09-10 10:38:04 +0200 | [diff] [blame] | 106 | |
Gavin Mak | ea2e330 | 2023-03-11 06:46:20 +0000 | [diff] [blame] | 107 | 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 Bohmer | 16c1328 | 2020-09-10 10:38:04 +0200 | [diff] [blame] | 112 | |
Gavin Mak | ea2e330 | 2023-03-11 06:46:20 +0000 | [diff] [blame] | 113 | 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 Bohmer | 16c1328 | 2020-09-10 10:38:04 +0200 | [diff] [blame] | 118 | |
Gavin Mak | ea2e330 | 2023-03-11 06:46:20 +0000 | [diff] [blame] | 119 | # 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 Bohmer | 16c1328 | 2020-09-10 10:38:04 +0200 | [diff] [blame] | 130 | |
Gavin Mak | ea2e330 | 2023-03-11 06:46:20 +0000 | [diff] [blame] | 131 | 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 Bohmer | 16c1328 | 2020-09-10 10:38:04 +0200 | [diff] [blame] | 137 | |
Gavin Mak | ea2e330 | 2023-03-11 06:46:20 +0000 | [diff] [blame] | 138 | def _CheckForHookApproval(self): |
| 139 | """Check to see whether this hook has been approved. |
Remy Bohmer | 16c1328 | 2020-09-10 10:38:04 +0200 | [diff] [blame] | 140 | |
Gavin Mak | ea2e330 | 2023-03-11 06:46:20 +0000 | [diff] [blame] | 141 | 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 Bohmer | 16c1328 | 2020-09-10 10:38:04 +0200 | [diff] [blame] | 144 | |
Gavin Mak | ea2e330 | 2023-03-11 06:46:20 +0000 | [diff] [blame] | 145 | 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 Bohmer | 16c1328 | 2020-09-10 10:38:04 +0200 | [diff] [blame] | 150 | |
Gavin Mak | ea2e330 | 2023-03-11 06:46:20 +0000 | [diff] [blame] | 151 | Returns: |
| 152 | True if this hook is approved to run; False otherwise. |
Remy Bohmer | 16c1328 | 2020-09-10 10:38:04 +0200 | [diff] [blame] | 153 | |
Gavin Mak | ea2e330 | 2023-03-11 06:46:20 +0000 | [diff] [blame] | 154 | 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 Bohmer | 16c1328 | 2020-09-10 10:38:04 +0200 | [diff] [blame] | 162 | |
Gavin Mak | ea2e330 | 2023-03-11 06:46:20 +0000 | [diff] [blame] | 163 | def _CheckForHookApprovalHelper( |
| 164 | self, subkey, new_val, main_prompt, changed_prompt |
| 165 | ): |
| 166 | """Check for approval for a particular attribute and hook. |
Remy Bohmer | 16c1328 | 2020-09-10 10:38:04 +0200 | [diff] [blame] | 167 | |
Gavin Mak | ea2e330 | 2023-03-11 06:46:20 +0000 | [diff] [blame] | 168 | 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 Bohmer | 16c1328 | 2020-09-10 10:38:04 +0200 | [diff] [blame] | 174 | |
Gavin Mak | ea2e330 | 2023-03-11 06:46:20 +0000 | [diff] [blame] | 175 | Returns: |
| 176 | True if this hook is approved to run; False otherwise. |
Remy Bohmer | 16c1328 | 2020-09-10 10:38:04 +0200 | [diff] [blame] | 177 | |
Gavin Mak | ea2e330 | 2023-03-11 06:46:20 +0000 | [diff] [blame] | 178 | 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 Bohmer | 16c1328 | 2020-09-10 10:38:04 +0200 | [diff] [blame] | 184 | |
Gavin Mak | ea2e330 | 2023-03-11 06:46:20 +0000 | [diff] [blame] | 185 | # Get the last value that the user approved for this hook; may be None. |
| 186 | old_val = hooks_config.GetString(git_approval_key) |
Remy Bohmer | 16c1328 | 2020-09-10 10:38:04 +0200 | [diff] [blame] | 187 | |
Gavin Mak | ea2e330 | 2023-03-11 06:46:20 +0000 | [diff] [blame] | 188 | 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 Bohmer | 16c1328 | 2020-09-10 10:38:04 +0200 | [diff] [blame] | 199 | |
Gavin Mak | ea2e330 | 2023-03-11 06:46:20 +0000 | [diff] [blame] | 200 | # 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 Bohmer | 16c1328 | 2020-09-10 10:38:04 +0200 | [diff] [blame] | 205 | |
Gavin Mak | ea2e330 | 2023-03-11 06:46:20 +0000 | [diff] [blame] | 206 | # 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 Bohmer | 16c1328 | 2020-09-10 10:38:04 +0200 | [diff] [blame] | 212 | |
Gavin Mak | ea2e330 | 2023-03-11 06:46:20 +0000 | [diff] [blame] | 213 | # 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 Bohmer | 16c1328 | 2020-09-10 10:38:04 +0200 | [diff] [blame] | 219 | |
Gavin Mak | ea2e330 | 2023-03-11 06:46:20 +0000 | [diff] [blame] | 220 | return False |
Remy Bohmer | 16c1328 | 2020-09-10 10:38:04 +0200 | [diff] [blame] | 221 | |
Gavin Mak | ea2e330 | 2023-03-11 06:46:20 +0000 | [diff] [blame] | 222 | 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 Bohmer | 16c1328 | 2020-09-10 10:38:04 +0200 | [diff] [blame] | 234 | |
Gavin Mak | ea2e330 | 2023-03-11 06:46:20 +0000 | [diff] [blame] | 235 | def _CheckForHookApprovalManifest(self): |
| 236 | """Check whether the user has approved this manifest host. |
Remy Bohmer | 16c1328 | 2020-09-10 10:38:04 +0200 | [diff] [blame] | 237 | |
Gavin Mak | ea2e330 | 2023-03-11 06:46:20 +0000 | [diff] [blame] | 238 | 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 Bohmer | 16c1328 | 2020-09-10 10:38:04 +0200 | [diff] [blame] | 248 | |
Gavin Mak | ea2e330 | 2023-03-11 06:46:20 +0000 | [diff] [blame] | 249 | def _CheckForHookApprovalHash(self): |
| 250 | """Check whether the user has approved the hooks repo. |
Remy Bohmer | 16c1328 | 2020-09-10 10:38:04 +0200 | [diff] [blame] | 251 | |
Gavin Mak | ea2e330 | 2023-03-11 06:46:20 +0000 | [diff] [blame] | 252 | 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 Bohmer | 16c1328 | 2020-09-10 10:38:04 +0200 | [diff] [blame] | 267 | |
Gavin Mak | ea2e330 | 2023-03-11 06:46:20 +0000 | [diff] [blame] | 268 | @staticmethod |
| 269 | def _ExtractInterpFromShebang(data): |
| 270 | """Extract the interpreter used in the shebang. |
Remy Bohmer | 16c1328 | 2020-09-10 10:38:04 +0200 | [diff] [blame] | 271 | |
Gavin Mak | ea2e330 | 2023-03-11 06:46:20 +0000 | [diff] [blame] | 272 | Try to locate the interpreter the script is using (ignoring `env`). |
Remy Bohmer | 16c1328 | 2020-09-10 10:38:04 +0200 | [diff] [blame] | 273 | |
Gavin Mak | ea2e330 | 2023-03-11 06:46:20 +0000 | [diff] [blame] | 274 | Args: |
| 275 | data: The file content of the script. |
Remy Bohmer | 16c1328 | 2020-09-10 10:38:04 +0200 | [diff] [blame] | 276 | |
Gavin Mak | ea2e330 | 2023-03-11 06:46:20 +0000 | [diff] [blame] | 277 | 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 Bohmer | 16c1328 | 2020-09-10 10:38:04 +0200 | [diff] [blame] | 284 | |
Gavin Mak | ea2e330 | 2023-03-11 06:46:20 +0000 | [diff] [blame] | 285 | # 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 Bohmer | 16c1328 | 2020-09-10 10:38:04 +0200 | [diff] [blame] | 290 | |
Gavin Mak | ea2e330 | 2023-03-11 06:46:20 +0000 | [diff] [blame] | 291 | # 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 Bohmer | 16c1328 | 2020-09-10 10:38:04 +0200 | [diff] [blame] | 295 | |
Gavin Mak | ea2e330 | 2023-03-11 06:46:20 +0000 | [diff] [blame] | 296 | return interp |
Remy Bohmer | 16c1328 | 2020-09-10 10:38:04 +0200 | [diff] [blame] | 297 | |
Gavin Mak | ea2e330 | 2023-03-11 06:46:20 +0000 | [diff] [blame] | 298 | def _ExecuteHookViaImport(self, data, context, **kwargs): |
| 299 | """Execute the hook code in |data| directly. |
Remy Bohmer | 16c1328 | 2020-09-10 10:38:04 +0200 | [diff] [blame] | 300 | |
Gavin Mak | ea2e330 | 2023-03-11 06:46:20 +0000 | [diff] [blame] | 301 | 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 Bohmer | 16c1328 | 2020-09-10 10:38:04 +0200 | [diff] [blame] | 305 | |
Gavin Mak | ea2e330 | 2023-03-11 06:46:20 +0000 | [diff] [blame] | 306 | 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 Bohmer | 16c1328 | 2020-09-10 10:38:04 +0200 | [diff] [blame] | 311 | try: |
Gavin Mak | ea2e330 | 2023-03-11 06:46:20 +0000 | [diff] [blame] | 312 | 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 Mak | ea2e330 | 2023-03-11 06:46:20 +0000 | [diff] [blame] | 375 | if interp: |
| 376 | prog = os.path.basename(interp) |
Mike Frysinger | 3b8f953 | 2023-10-14 01:25:50 +0545 | [diff] [blame^] | 377 | if prog.startswith("python2"): |
| 378 | raise HookError("Python 2 is not supported") |
Remy Bohmer | 16c1328 | 2020-09-10 10:38:04 +0200 | [diff] [blame] | 379 | |
Gavin Mak | ea2e330 | 2023-03-11 06:46:20 +0000 | [diff] [blame] | 380 | # Run the hook by importing directly. |
Mike Frysinger | 3b8f953 | 2023-10-14 01:25:50 +0545 | [diff] [blame^] | 381 | self._ExecuteHookViaImport(data, context, **kwargs) |
Gavin Mak | ea2e330 | 2023-03-11 06:46:20 +0000 | [diff] [blame] | 382 | finally: |
| 383 | # Restore sys.path and CWD. |
| 384 | sys.path = orig_syspath |
| 385 | os.chdir(orig_path) |
Remy Bohmer | 7f7acfe | 2020-08-01 18:36:44 +0200 | [diff] [blame] | 386 | |
Gavin Mak | ea2e330 | 2023-03-11 06:46:20 +0000 | [diff] [blame] | 387 | 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 Bohmer | 16c1328 | 2020-09-10 10:38:04 +0200 | [diff] [blame] | 393 | |
Gavin Mak | ea2e330 | 2023-03-11 06:46:20 +0000 | [diff] [blame] | 394 | def Run(self, **kwargs): |
| 395 | """Run the hook. |
Remy Bohmer | 16c1328 | 2020-09-10 10:38:04 +0200 | [diff] [blame] | 396 | |
Gavin Mak | ea2e330 | 2023-03-11 06:46:20 +0000 | [diff] [blame] | 397 | 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 Bohmer | 16c1328 | 2020-09-10 10:38:04 +0200 | [diff] [blame] | 399 | |
Gavin Mak | ea2e330 | 2023-03-11 06:46:20 +0000 | [diff] [blame] | 400 | 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 Bohmer | 16c1328 | 2020-09-10 10:38:04 +0200 | [diff] [blame] | 406 | |
Gavin Mak | ea2e330 | 2023-03-11 06:46:20 +0000 | [diff] [blame] | 407 | 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 Bohmer | 16c1328 | 2020-09-10 10:38:04 +0200 | [diff] [blame] | 426 | |
Gavin Mak | ea2e330 | 2023-03-11 06:46:20 +0000 | [diff] [blame] | 427 | passed = True |
| 428 | try: |
| 429 | self._CheckHook() |
Remy Bohmer | 16c1328 | 2020-09-10 10:38:04 +0200 | [diff] [blame] | 430 | |
Gavin Mak | ea2e330 | 2023-03-11 06:46:20 +0000 | [diff] [blame] | 431 | # 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 Bohmer | 7f7acfe | 2020-08-01 18:36:44 +0200 | [diff] [blame] | 445 | |
Gavin Mak | ea2e330 | 2023-03-11 06:46:20 +0000 | [diff] [blame] | 446 | 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 Bohmer | 7f7acfe | 2020-08-01 18:36:44 +0200 | [diff] [blame] | 453 | |
Gavin Mak | ea2e330 | 2023-03-11 06:46:20 +0000 | [diff] [blame] | 454 | return passed |
Remy Bohmer | 7f7acfe | 2020-08-01 18:36:44 +0200 | [diff] [blame] | 455 | |
Gavin Mak | ea2e330 | 2023-03-11 06:46:20 +0000 | [diff] [blame] | 456 | @classmethod |
| 457 | def FromSubcmd(cls, manifest, opt, *args, **kwargs): |
| 458 | """Method to construct the repo hook class |
Remy Bohmer | 7f7acfe | 2020-08-01 18:36:44 +0200 | [diff] [blame] | 459 | |
Gavin Mak | ea2e330 | 2023-03-11 06:46:20 +0000 | [diff] [blame] | 460 | 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 Bohmer | 7f7acfe | 2020-08-01 18:36:44 +0200 | [diff] [blame] | 479 | |
Gavin Mak | ea2e330 | 2023-03-11 06:46:20 +0000 | [diff] [blame] | 480 | @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 | ) |