-
-
Notifications
You must be signed in to change notification settings - Fork 937
Override bash executable, defaulting to Git for Windows git bash over WSL bash #1791
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 4 commits
77f74ce
4bde10d
6676668
50c74f4
bf919f6
f065d1f
0c14cac
8200ad1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -11,12 +11,13 @@ | |
import logging | ||
import os | ||
import signal | ||
from subprocess import Popen, PIPE, DEVNULL | ||
from subprocess import Popen, PIPE, DEVNULL, run, CalledProcessError | ||
import subprocess | ||
import threading | ||
from textwrap import dedent | ||
from pathlib import Path | ||
|
||
from git.compat import defenc, force_bytes, safe_decode | ||
from git.compat import defenc, force_bytes, safe_decode, is_win | ||
from git.exc import ( | ||
CommandError, | ||
GitCommandError, | ||
|
@@ -305,6 +306,173 @@ def __setstate__(self, d: Dict[str, Any]) -> None: | |
the top level ``__init__``. | ||
""" | ||
|
||
_bash_exec_env_var = "GIT_PYTHON_BASH_EXECUTABLE" | ||
|
||
bash_exec_name = "bash" | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This seems not to be used. I'm not sure it's needed. If it is kept, then |
||
"""Default bash command that should work on Linux, Windows, and other systems.""" | ||
emanspeaks marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
GIT_PYTHON_BASH_EXECUTABLE = None | ||
"""Provide the full path to the bash executable. Otherwise it assumes bash is in the path. | ||
emanspeaks marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
Note that the bash executable is actually found during the refresh step in | ||
the top level ``__init__``. | ||
""" | ||
|
||
@classmethod | ||
def _get_default_bash_path(cls): | ||
emanspeaks marked this conversation as resolved.
Show resolved
Hide resolved
|
||
# Assumes that, if user is running in Windows, they probably are using | ||
# Git for Windows, which includes Git BASH and should be associated | ||
# with the configured Git command set in `refresh()`. Regardless of | ||
# if the Git command assumes it is installed in (root)/cmd/git.exe or | ||
# (root)/bin/git.exe, the root is always up two levels from the git | ||
# command. Try going up to levels from the currently configured | ||
# git command, then navigate to (root)/bin/bash.exe. If this exists, | ||
emanspeaks marked this conversation as resolved.
Show resolved
Hide resolved
|
||
# prefer it over the WSL version in System32, direct access to which | ||
# is reportedly deprecated. Fail back to default "bash.exe" if | ||
# the Git for Windows lookup doesn't work. | ||
emanspeaks marked this conversation as resolved.
Show resolved
Hide resolved
|
||
# | ||
# This addresses issues where git hooks are intended to run assuming | ||
# the "native" Windows environment as seen by git.exe rather than | ||
# inside the git sandbox of WSL, which is likely configured | ||
# independently of the Windows Git. A noteworthy example are repos | ||
# with Git LFS, where Git LFS may be installed in Windows but not | ||
# in WSL. | ||
emanspeaks marked this conversation as resolved.
Show resolved
Hide resolved
|
||
if not is_win: | ||
emanspeaks marked this conversation as resolved.
Show resolved
Hide resolved
|
||
return "bash" | ||
try: | ||
wheregit = run(["where", Git.GIT_PYTHON_GIT_EXECUTABLE], check=True, stdout=PIPE).stdout | ||
emanspeaks marked this conversation as resolved.
Show resolved
Hide resolved
emanspeaks marked this conversation as resolved.
Show resolved
Hide resolved
|
||
except CalledProcessError: | ||
return "bash.exe" | ||
gitpath = Path(wheregit.decode(defenc).splitlines()[0]) | ||
gitroot = gitpath.parent.parent | ||
gitbash = gitroot / "bin" / "bash.exe" | ||
return str(gitbash) if gitbash.exists() else "bash.exe" | ||
|
||
@classmethod | ||
def refresh_bash(cls, path: Union[None, PathLike] = None) -> bool: | ||
emanspeaks marked this conversation as resolved.
Show resolved
Hide resolved
|
||
"""This gets called by the refresh function (see the top level __init__).""" | ||
emanspeaks marked this conversation as resolved.
Show resolved
Hide resolved
|
||
# Discern which path to refresh with. | ||
if path is not None: | ||
new_bash = os.path.expanduser(path) | ||
new_bash = os.path.abspath(new_bash) | ||
else: | ||
new_bash = os.environ.get(cls._bash_exec_env_var) | ||
if not new_bash: | ||
new_bash = cls._get_default_bash_path() | ||
|
||
# Keep track of the old and new bash executable path. | ||
old_bash = cls.GIT_PYTHON_BASH_EXECUTABLE | ||
cls.GIT_PYTHON_BASH_EXECUTABLE = new_bash | ||
|
||
# Test if the new git executable path is valid. A GitCommandNotFound error is | ||
# spawned by us. A PermissionError is spawned if the git executable cannot be | ||
# executed for whatever reason. | ||
emanspeaks marked this conversation as resolved.
Show resolved
Hide resolved
|
||
has_bash = False | ||
try: | ||
run([cls.GIT_PYTHON_BASH_EXECUTABLE, "--version"], check=True, stdout=PIPE) | ||
emanspeaks marked this conversation as resolved.
Show resolved
Hide resolved
|
||
has_bash = True | ||
except CalledProcessError: | ||
pass | ||
|
||
# Warn or raise exception if test failed. | ||
emanspeaks marked this conversation as resolved.
Show resolved
Hide resolved
|
||
if not has_bash: | ||
err = dedent( | ||
f"""\ | ||
Bad bash executable. | ||
The bash executable must be specified in one of the following ways: | ||
- be included in your $PATH | ||
- be set via ${cls._bash_exec_env_var} | ||
- explicitly set via git.refresh_bash() | ||
emanspeaks marked this conversation as resolved.
Show resolved
Hide resolved
|
||
""" | ||
) | ||
|
||
# Revert to whatever the old_bash was. | ||
cls.GIT_PYTHON_BASH_EXECUTABLE = old_bash | ||
|
||
if old_bash is None: | ||
# On the first refresh (when GIT_PYTHON_GIT_EXECUTABLE is None) we only | ||
emanspeaks marked this conversation as resolved.
Show resolved
Hide resolved
|
||
# are quiet, warn, or error depending on the GIT_PYTHON_REFRESH value. | ||
emanspeaks marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
# Determine what the user wants to happen during the initial refresh we | ||
# expect GIT_PYTHON_REFRESH to either be unset or be one of the | ||
# following values: | ||
# | ||
# 0|q|quiet|s|silence|n|none | ||
# 1|w|warn|warning | ||
# 2|r|raise|e|error | ||
|
||
mode = os.environ.get(cls._refresh_env_var, "raise").lower() | ||
Byron marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
quiet = ["quiet", "q", "silence", "s", "none", "n", "0"] | ||
warn = ["warn", "w", "warning", "1"] | ||
error = ["error", "e", "raise", "r", "2"] | ||
|
||
if mode in quiet: | ||
pass | ||
elif mode in warn or mode in error: | ||
err = ( | ||
dedent( | ||
"""\ | ||
%s | ||
All commit hook commands will error until this is rectified. | ||
|
||
This initial warning can be silenced or aggravated in the future by setting the | ||
$%s environment variable. Use one of the following values: | ||
- %s: for no warning or exception | ||
- %s: for a printed warning | ||
- %s: for a raised exception | ||
|
||
Example: | ||
export %s=%s | ||
""" | ||
) | ||
% ( | ||
err, | ||
cls._refresh_env_var, | ||
"|".join(quiet), | ||
"|".join(warn), | ||
"|".join(error), | ||
cls._refresh_env_var, | ||
quiet[0], | ||
) | ||
) | ||
|
||
if mode in warn: | ||
print("WARNING: %s" % err) | ||
else: | ||
raise ImportError(err) | ||
else: | ||
err = ( | ||
dedent( | ||
"""\ | ||
%s environment variable has been set but it has been set with an invalid value. | ||
|
||
Use only the following values: | ||
- %s: for no warning or exception | ||
- %s: for a printed warning | ||
- %s: for a raised exception | ||
""" | ||
) | ||
% ( | ||
cls._refresh_env_var, | ||
"|".join(quiet), | ||
"|".join(warn), | ||
"|".join(error), | ||
) | ||
) | ||
raise ImportError(err) | ||
emanspeaks marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
# We get here if this was the init refresh and the refresh mode was not | ||
# error. Go ahead and set the GIT_PYTHON_BASH_EXECUTABLE such that we | ||
# discern the difference between a first import and a second import. | ||
emanspeaks marked this conversation as resolved.
Show resolved
Hide resolved
|
||
cls.GIT_PYTHON_BASH_EXECUTABLE = cls.bash_exec_name | ||
else: | ||
# After the first refresh (when GIT_PYTHON_BASH_EXECUTABLE is no longer | ||
# None) we raise an exception. | ||
emanspeaks marked this conversation as resolved.
Show resolved
Hide resolved
|
||
raise GitCommandNotFound("bash", err) | ||
emanspeaks marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
return has_bash | ||
|
||
@classmethod | ||
def refresh(cls, path: Union[None, PathLike] = None) -> bool: | ||
"""This gets called by the refresh function (see the top level __init__).""" | ||
|
Uh oh!
There was an error while loading. Please reload this page.