Skip to content
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

Override bash executable, defaulting to Git for Windows git bash over WSL bash #1791

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
Open
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AUTHORS
Original file line number Diff line number Diff line change
@@ -53,5 +53,6 @@ Contributors are:
-Santos Gallegos <stsewd _at_ proton.me>
-Wenhan Zhu <wzhu.cosmos _at_ gmail.com>
-Eliah Kagan <eliah.kagan _at_ gmail.com>
-Randy Eckman <emanspeaks _at_ gmail.com>

Portions derived from other open source works and are clearly marked.
1 change: 1 addition & 0 deletions git/__init__.py
Original file line number Diff line number Diff line change
@@ -126,6 +126,7 @@ def refresh(path: Optional[PathLike] = None) -> None:

if not Git.refresh(path=path):
return
Git.refresh_bash()
if not FetchInfo.refresh(): # noqa: F405
return # type: ignore [unreachable]

172 changes: 170 additions & 2 deletions git/cmd.py
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"
Copy link
Member

Choose a reason for hiding this comment

The 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 _get_default_bash_path should probably return it on non-Windows systems and possibly return f"{cla.bash_exec_name}.exe" as the fallback on Windows systems. Although I believe it was for symmetry with git_exec_name, the treatment of git and bash has become less similar in recent changes, so I'm not sure there's a need for this anymore.

"""Default bash command that should work on Linux, Windows, and other systems."""

GIT_PYTHON_BASH_EXECUTABLE = None
"""Provide the full path to the bash executable. Otherwise it assumes bash is in the path.

Note that the bash executable is actually found during the refresh step in
the top level ``__init__``.
"""

@classmethod
def _get_default_bash_path(cls):
# 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,
# 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.
#
# 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.
if not is_win:
return "bash"
try:
wheregit = run(["where", Git.GIT_PYTHON_GIT_EXECUTABLE], check=True, stdout=PIPE).stdout
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:
"""This gets called by the refresh function (see the top level __init__)."""
# 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.
has_bash = False
try:
run([cls.GIT_PYTHON_BASH_EXECUTABLE, "--version"], check=True, stdout=PIPE)
has_bash = True
except CalledProcessError:
pass

# Warn or raise exception if test failed.
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()
"""
)

# 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
# are quiet, warn, or error depending on the GIT_PYTHON_REFRESH value.

# 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()

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)

# 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.
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.
raise GitCommandNotFound("bash", err)

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__)."""
4 changes: 2 additions & 2 deletions git/index/fun.py
Original file line number Diff line number Diff line change
@@ -18,7 +18,7 @@
)
import subprocess

from git.cmd import PROC_CREATIONFLAGS, handle_process_output
from git.cmd import PROC_CREATIONFLAGS, handle_process_output, Git
from git.compat import defenc, force_bytes, force_text, safe_decode
from git.exc import HookExecutionError, UnmergedEntriesError
from git.objects.fun import (
@@ -96,7 +96,7 @@ def run_commit_hook(name: str, index: "IndexFile", *args: str) -> None:
# Windows only uses extensions to determine how to open files
# (doesn't understand shebangs). Try using bash to run the hook.
relative_hp = Path(hp).relative_to(index.repo.working_dir).as_posix()
cmd = ["bash.exe", relative_hp]
cmd = [Git.GIT_PYTHON_BASH_EXECUTABLE, relative_hp]

process = subprocess.Popen(
cmd + list(args),