|
11 | 11 | import logging
|
12 | 12 | import os
|
13 | 13 | import signal
|
14 |
| -from subprocess import Popen, PIPE, DEVNULL |
| 14 | +from subprocess import Popen, PIPE, DEVNULL, run, CalledProcessError |
15 | 15 | import subprocess
|
16 | 16 | import threading
|
17 | 17 | from textwrap import dedent
|
| 18 | +from pathlib import Path |
18 | 19 |
|
19 |
| -from git.compat import defenc, force_bytes, safe_decode |
| 20 | +from git.compat import defenc, force_bytes, safe_decode, is_win |
20 | 21 | from git.exc import (
|
21 | 22 | CommandError,
|
22 | 23 | GitCommandError,
|
@@ -305,6 +306,175 @@ def __setstate__(self, d: Dict[str, Any]) -> None:
|
305 | 306 | the top level ``__init__``.
|
306 | 307 | """
|
307 | 308 |
|
| 309 | + _bash_exec_env_var = "GIT_PYTHON_BASH_EXECUTABLE" |
| 310 | + |
| 311 | + bash_exec_name = "bash" |
| 312 | + """Default bash command that should work on Linux, Windows, and other systems.""" |
| 313 | + |
| 314 | + GIT_PYTHON_BASH_EXECUTABLE = None |
| 315 | + """Provide the full path to the bash executable. Otherwise it assumes bash is in the path. |
| 316 | +
|
| 317 | + Note that the bash executable is actually found during the refresh step in |
| 318 | + the top level ``__init__``. |
| 319 | + """ |
| 320 | + |
| 321 | + @classmethod |
| 322 | + def _get_default_bash_path(cls): |
| 323 | + # Assumes that, if user is running in Windows, they probably are using |
| 324 | + # Git for Windows, which includes Git BASH and should be associated |
| 325 | + # with the configured Git command set in `refresh()`. Regardless of |
| 326 | + # if the Git command assumes it is installed in (root)/cmd/git.exe or |
| 327 | + # (root)/bin/git.exe, the root is always up two levels from the git |
| 328 | + # command. Try going up to levels from the currently configured |
| 329 | + # git command, then navigate to (root)/bin/bash.exe. If this exists, |
| 330 | + # prefer it over the WSL version in System32, direct access to which |
| 331 | + # is reportedly deprecated. Fail back to default "bash.exe" if |
| 332 | + # the Git for Windows lookup doesn't work. |
| 333 | + # |
| 334 | + # This addresses issues where git hooks are intended to run assuming |
| 335 | + # the "native" Windows environment as seen by git.exe rather than |
| 336 | + # inside the git sandbox of WSL, which is likely configured |
| 337 | + # independetly of the Windows Git. A noteworthy example are repos with |
| 338 | + # Git LFS, where Git LFS may be installed in Windows but not in WSL. |
| 339 | + if not is_win: |
| 340 | + return 'bash' |
| 341 | + try: |
| 342 | + wheregit = run(['where', Git.GIT_PYTHON_GIT_EXECUTABLE], |
| 343 | + check=True, stdout=PIPE).stdout |
| 344 | + except CalledProcessError: |
| 345 | + return 'bash.exe' |
| 346 | + gitpath = Path(wheregit.decode(defenc).splitlines()[0]) |
| 347 | + gitroot = gitpath.parent.parent |
| 348 | + gitbash = gitroot / 'bin' / 'bash.exe' |
| 349 | + return str(gitbash) if gitbash.exists else 'bash.exe' |
| 350 | + |
| 351 | + @classmethod |
| 352 | + def refresh_bash(cls, path: Union[None, PathLike] = None) -> bool: |
| 353 | + """This gets called by the refresh function (see the top level __init__).""" |
| 354 | + # Discern which path to refresh with. |
| 355 | + if path is not None: |
| 356 | + new_bash = os.path.expanduser(path) |
| 357 | + new_bash = os.path.abspath(new_bash) |
| 358 | + else: |
| 359 | + new_bash = os.environ.get(cls._bash_exec_env_var) |
| 360 | + if new_bash is None: |
| 361 | + new_bash = cls._get_default_bash_path() |
| 362 | + |
| 363 | + # Keep track of the old and new bash executable path. |
| 364 | + old_bash = cls.GIT_PYTHON_BASH_EXECUTABLE |
| 365 | + cls.GIT_PYTHON_BASH_EXECUTABLE = new_bash |
| 366 | + |
| 367 | + # Test if the new git executable path is valid. A GitCommandNotFound error is |
| 368 | + # spawned by us. A PermissionError is spawned if the git executable cannot be |
| 369 | + # executed for whatever reason. |
| 370 | + has_bash = False |
| 371 | + try: |
| 372 | + run([cls.GIT_PYTHON_BASH_EXECUTABLE, '--version']) |
| 373 | + has_bash = True |
| 374 | + except CalledProcessError: |
| 375 | + pass |
| 376 | + |
| 377 | + # Warn or raise exception if test failed. |
| 378 | + if not has_bash: |
| 379 | + err = ( |
| 380 | + dedent( |
| 381 | + f"""\ |
| 382 | + Bad bash executable. |
| 383 | + The bash executable must be specified in one of the following ways: |
| 384 | + - be included in your $PATH |
| 385 | + - be set via ${cls._bash_exec_env_var} |
| 386 | + - explicitly set via git.refresh_bash() |
| 387 | + """ |
| 388 | + ) |
| 389 | + ) |
| 390 | + |
| 391 | + # Revert to whatever the old_bash was. |
| 392 | + cls.GIT_PYTHON_BASH_EXECUTABLE = old_bash |
| 393 | + |
| 394 | + if old_bash is None: |
| 395 | + # On the first refresh (when GIT_PYTHON_GIT_EXECUTABLE is None) we only |
| 396 | + # are quiet, warn, or error depending on the GIT_PYTHON_REFRESH value. |
| 397 | + |
| 398 | + # Determine what the user wants to happen during the initial refresh we |
| 399 | + # expect GIT_PYTHON_REFRESH to either be unset or be one of the |
| 400 | + # following values: |
| 401 | + # |
| 402 | + # 0|q|quiet|s|silence|n|none |
| 403 | + # 1|w|warn|warning |
| 404 | + # 2|r|raise|e|error |
| 405 | + |
| 406 | + mode = os.environ.get(cls._refresh_env_var, "raise").lower() |
| 407 | + |
| 408 | + quiet = ["quiet", "q", "silence", "s", "none", "n", "0"] |
| 409 | + warn = ["warn", "w", "warning", "1"] |
| 410 | + error = ["error", "e", "raise", "r", "2"] |
| 411 | + |
| 412 | + if mode in quiet: |
| 413 | + pass |
| 414 | + elif mode in warn or mode in error: |
| 415 | + err = ( |
| 416 | + dedent( |
| 417 | + """\ |
| 418 | + %s |
| 419 | + All commit hook commands will error until this is rectified. |
| 420 | +
|
| 421 | + This initial warning can be silenced or aggravated in the future by setting the |
| 422 | + $%s environment variable. Use one of the following values: |
| 423 | + - %s: for no warning or exception |
| 424 | + - %s: for a printed warning |
| 425 | + - %s: for a raised exception |
| 426 | +
|
| 427 | + Example: |
| 428 | + export %s=%s |
| 429 | + """ |
| 430 | + ) |
| 431 | + % ( |
| 432 | + err, |
| 433 | + cls._refresh_env_var, |
| 434 | + "|".join(quiet), |
| 435 | + "|".join(warn), |
| 436 | + "|".join(error), |
| 437 | + cls._refresh_env_var, |
| 438 | + quiet[0], |
| 439 | + ) |
| 440 | + ) |
| 441 | + |
| 442 | + if mode in warn: |
| 443 | + print("WARNING: %s" % err) |
| 444 | + else: |
| 445 | + raise ImportError(err) |
| 446 | + else: |
| 447 | + err = ( |
| 448 | + dedent( |
| 449 | + """\ |
| 450 | + %s environment variable has been set but it has been set with an invalid value. |
| 451 | +
|
| 452 | + Use only the following values: |
| 453 | + - %s: for no warning or exception |
| 454 | + - %s: for a printed warning |
| 455 | + - %s: for a raised exception |
| 456 | + """ |
| 457 | + ) |
| 458 | + % ( |
| 459 | + cls._refresh_env_var, |
| 460 | + "|".join(quiet), |
| 461 | + "|".join(warn), |
| 462 | + "|".join(error), |
| 463 | + ) |
| 464 | + ) |
| 465 | + raise ImportError(err) |
| 466 | + |
| 467 | + # We get here if this was the init refresh and the refresh mode was not |
| 468 | + # error. Go ahead and set the GIT_PYTHON_BASH_EXECUTABLE such that we |
| 469 | + # discern the difference between a first import and a second import. |
| 470 | + cls.GIT_PYTHON_BASH_EXECUTABLE = cls.bash_exec_name |
| 471 | + else: |
| 472 | + # After the first refresh (when GIT_PYTHON_BASH_EXECUTABLE is no longer |
| 473 | + # None) we raise an exception. |
| 474 | + raise GitCommandNotFound("bash", err) |
| 475 | + |
| 476 | + return has_bash |
| 477 | + |
308 | 478 | @classmethod
|
309 | 479 | def refresh(cls, path: Union[None, PathLike] = None) -> bool:
|
310 | 480 | """This gets called by the refresh function (see the top level __init__)."""
|
|
0 commit comments