Repository: gitpython-developers/GitPython

Issue:
Permission denied on `config.lock` when cloning in Docker under MacOS
Hi,
I found a bug related to the `FileLock` system. What I'm doing:
1. In Ubuntu 20.04 running under Docker, I'm cloning the repository using `clone_from`.
2. The directory that I'm cloning to is mounted under MacOS as follows:
`docker run --rm --interactive -v $PWD:/work --user $(id -u):$(id -g) /bin/bash`
3. Docker container is running with UID and GID of the MacOS

I'm getting this error:
```
Exception in thread my-repository:
Traceback (most recent call last):
  File "/usr/local/lib/python3.8/dist-packages/git/util.py", line 941, in _obtain_lock_or_raise
    fd = os.open(lock_file, flags, 0)
PermissionError: [Errno 13] Permission denied: '/work/repo/my-repository.git/.git/config.lock'

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "/usr/lib/python3.8/threading.py", line 932, in _bootstrap_inner
    self.run()
  File "/app/clone-tool.py", line 124, in run
    git.Repo.clone_from(self.git_url, repo_dir)
  File "/usr/local/lib/python3.8/dist-packages/git/repo/base.py", line 1325, in clone_from
    return cls._clone(
  File "/usr/local/lib/python3.8/dist-packages/git/repo/base.py", line 1252, in _clone
    with repo.remotes[0].config_writer as writer:
  File "/usr/local/lib/python3.8/dist-packages/git/remote.py", line 1146, in config_writer
    writer = self.repo.config_writer()
  File "/usr/local/lib/python3.8/dist-packages/git/repo/base.py", line 604, in config_writer
    return GitConfigParser(self._get_config_path(config_level), read_only=False, repo=self)
  File "/usr/local/lib/python3.8/dist-packages/git/config.py", line 366, in __init__
    self._acquire_lock()
  File "/usr/local/lib/python3.8/dist-packages/git/config.py", line 385, in _acquire_lock
    self._lock._obtain_lock()
  File "/usr/local/lib/python3.8/dist-packages/git/util.py", line 951, in _obtain_lock
    return self._obtain_lock_or_raise()
  File "/usr/local/lib/python3.8/dist-packages/git/util.py", line 944, in _obtain_lock_or_raise
    raise IOError(str(e)) from e
OSError: [Errno 13] Permission denied: '/work/repo/my-repository.git/.git/config.lock'
```

The `config.lock` file as listed under Ubuntu:
```
$ ls -la
ls: cannot access 'config.lock': No such file or directory
total 144
drwxr-xr-x 14 501 dialout    448 Mar 24 13:42 .
drwxr-xr-x 18 501 dialout    576 Mar 24 13:42 ..
-rw-r--r--  1 501 dialout     23 Mar 24 13:42 HEAD
drwxr-xr-x  2 501 dialout     64 Mar 24 13:42 branches
-rw-r--r--  1 501 dialout    298 Mar 24 13:42 config
-?????????  ? ?   ?            ?            ? config.lock
-rw-r--r--  1 501 dialout     73 Mar 24 13:42 description
drwxr-xr-x 14 501 dialout    448 Mar 24 13:42 hooks
-rw-r--r--  1 501 dialout 122075 Mar 24 13:42 index
drwxr-xr-x  3 501 dialout     96 Mar 24 13:42 info
drwxr-xr-x  4 501 dialout    128 Mar 24 13:42 logs
drwxr-xr-x  4 501 dialout    128 Mar 24 13:42 objects
-rw-r--r--  1 501 dialout    114 Mar 24 13:42 packed-refs
drwxr-xr-x  5 501 dialout    160 Mar 24 13:42 refs
```

Also listed under MacOS:
```
$ ls -la
total 288
drwxr-xr-x  14 mmajchrzycki  staff     448 Mar 24 14:42 .
drwxr-xr-x  18 mmajchrzycki  staff     576 Mar 24 14:42 ..
-rw-r--r--   1 mmajchrzycki  staff      23 Mar 24 14:42 HEAD
drwxr-xr-x   2 mmajchrzycki  staff      64 Mar 24 14:42 branches
-rw-r--r--@  1 mmajchrzycki  staff     298 Mar 24 14:42 config
----------   1 mmajchrzycki  staff       0 Mar 24 14:42 config.lock
-rw-r--r--   1 mmajchrzycki  staff      73 Mar 24 14:42 description
drwxr-xr-x  14 mmajchrzycki  staff     448 Mar 24 14:42 hooks
-rw-r--r--   1 mmajchrzycki  staff  122075 Mar 24 14:42 index
drwxr-xr-x   3 mmajchrzycki  staff      96 Mar 24 14:42 info
drwxr-xr-x   4 mmajchrzycki  staff     128 Mar 24 14:42 logs
drwxr-xr-x   4 mmajchrzycki  staff     128 Mar 24 14:42 objects
-rw-r--r--   1 mmajchrzycki  staff     114 Mar 24 14:42 packed-refs
```

I will provide a minimum set-up (docker file and python script) to reproduce it later.


Comments:

Patch:
diff --git a/git/cmd.py b/git/cmd.py
index 3d170fac..3665eb02 100644
--- a/git/cmd.py
+++ b/git/cmd.py
@@ -5,7 +5,7 @@
 # the BSD License: http://www.opensource.org/licenses/bsd-license.php
 from __future__ import annotations
 import re
-from contextlib import contextmanager
+import contextlib
 import io
 import logging
 import os
@@ -14,6 +14,7 @@ from subprocess import call, Popen, PIPE, DEVNULL
 import subprocess
 import threading
 from textwrap import dedent
+import unittest.mock
 
 from git.compat import (
     defenc,
@@ -963,8 +964,11 @@ class Git(LazyMixin):
                     redacted_command,
                     '"kill_after_timeout" feature is not supported on Windows.',
                 )
+            # Only search PATH, not CWD. This must be in the *caller* environment. The "1" can be any value.
+            patch_caller_env = unittest.mock.patch.dict(os.environ, {"NoDefaultCurrentDirectoryInExePath": "1"})
         else:
             cmd_not_found_exception = FileNotFoundError  # NOQA # exists, flake8 unknown @UndefinedVariable
+            patch_caller_env = contextlib.nullcontext()
         # end handle
 
         stdout_sink = PIPE if with_stdout else getattr(subprocess, "DEVNULL", None) or open(os.devnull, "wb")
@@ -980,21 +984,21 @@ class Git(LazyMixin):
             istream_ok,
         )
         try:
-            proc = Popen(
-                command,
-                env=env,
-                cwd=cwd,
-                bufsize=-1,
-                stdin=istream or DEVNULL,
-                stderr=PIPE,
-                stdout=stdout_sink,
-                shell=shell is not None and shell or self.USE_SHELL,
-                close_fds=is_posix,  # unsupported on windows
-                universal_newlines=universal_newlines,
-                creationflags=PROC_CREATIONFLAGS,
-                **subprocess_kwargs,
-            )
-
+            with patch_caller_env:
+                proc = Popen(
+                    command,
+                    env=env,
+                    cwd=cwd,
+                    bufsize=-1,
+                    stdin=istream or DEVNULL,
+                    stderr=PIPE,
+                    stdout=stdout_sink,
+                    shell=shell is not None and shell or self.USE_SHELL,
+                    close_fds=is_posix,  # unsupported on windows
+                    universal_newlines=universal_newlines,
+                    creationflags=PROC_CREATIONFLAGS,
+                    **subprocess_kwargs,
+                )
         except cmd_not_found_exception as err:
             raise GitCommandNotFound(redacted_command, err) from err
         else:
@@ -1144,7 +1148,7 @@ class Git(LazyMixin):
                 del self._environment[key]
         return old_env
 
-    @contextmanager
+    @contextlib.contextmanager
     def custom_environment(self, **kwargs: Any) -> Iterator[None]:
         """
         A context manager around the above ``update_environment`` method to restore the
diff --git a/git/repo/base.py b/git/repo/base.py
index ab202654..113fca45 100644
--- a/git/repo/base.py
+++ b/git/repo/base.py
@@ -60,6 +60,7 @@ from git.types import (
     PathLike,
     Lit_config_levels,
     Commit_ish,
+    CallableProgress,
     Tree_ish,
     assert_never,
 )
@@ -1258,7 +1259,7 @@ class Repo(object):
     def clone(
         self,
         path: PathLike,
-        progress: Optional[Callable] = None,
+        progress: Optional[CallableProgress] = None,
         multi_options: Optional[List[str]] = None,
         allow_unsafe_protocols: bool = False,
         allow_unsafe_options: bool = False,
@@ -1297,7 +1298,7 @@ class Repo(object):
         cls,
         url: PathLike,
         to_path: PathLike,
-        progress: Optional[Callable] = None,
+        progress: CallableProgress = None,
         env: Optional[Mapping[str, str]] = None,
         multi_options: Optional[List[str]] = None,
         allow_unsafe_protocols: bool = False,
diff --git a/git/types.py b/git/types.py
index 9064ecbf..9f862172 100644
--- a/git/types.py
+++ b/git/types.py
@@ -8,42 +8,39 @@ import sys
 from typing import (
     Dict,
     NoReturn,
-    Sequence,
+    Sequence as Sequence,
     Tuple,
     Union,
     Any,
+    Optional,
+    Callable,
     TYPE_CHECKING,
     TypeVar,
 )  # noqa: F401
 
-if sys.version_info[:2] >= (3, 8):
+if sys.version_info >= (3, 8):
     from typing import (
         Literal,
-        SupportsIndex,
         TypedDict,
         Protocol,
+        SupportsIndex as SupportsIndex,
         runtime_checkable,
     )  # noqa: F401
 else:
     from typing_extensions import (
         Literal,
-        SupportsIndex,  # noqa: F401
+        SupportsIndex as SupportsIndex,
         TypedDict,
         Protocol,
         runtime_checkable,
     )  # noqa: F401
 
-# if sys.version_info[:2] >= (3, 10):
+# if sys.version_info >= (3, 10):
 #     from typing import TypeGuard  # noqa: F401
 # else:
 #     from typing_extensions import TypeGuard  # noqa: F401
 
-
-if sys.version_info[:2] < (3, 9):
-    PathLike = Union[str, os.PathLike]
-else:
-    # os.PathLike only becomes subscriptable from Python 3.9 onwards
-    PathLike = Union[str, os.PathLike[str]]
+PathLike = Union[str, "os.PathLike[str]"]
 
 if TYPE_CHECKING:
     from git.repo import Repo
@@ -62,6 +59,9 @@ Lit_commit_ish = Literal["commit", "tag", "blob", "tree"]
 
 Lit_config_levels = Literal["system", "global", "user", "repository"]
 
+# Progress parameter type alias -----------------------------------------
+
+CallableProgress = Optional[Callable[[int, Union[str, float], Union[str, float, None], str], None]]
 
 # def is_config_level(inp: str) -> TypeGuard[Lit_config_levels]:
 #     # return inp in get_args(Lit_config_level)  # only py >= 3.8
diff --git a/git/util.py b/git/util.py
index 0ef8bdeb..a3748f0f 100644
--- a/git/util.py
+++ b/git/util.py
@@ -935,11 +935,7 @@ class LockFile(object):
             )
 
         try:
-            flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
-            if is_win:
-                flags |= os.O_SHORT_LIVED
-            fd = os.open(lock_file, flags, 0)
-            os.close(fd)
+            open(lock_file, mode='w', closefd=True)
         except OSError as e:
             raise IOError(str(e)) from e
 
diff --git a/requirements-dev.txt b/requirements-dev.txt
index bacde349..946b4c94 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -10,4 +10,4 @@ pytest-icdiff
 # pytest-profiling
 
 
-tox
\ No newline at end of file
+tox


Test patch:
diff --git a/test/test_git.py b/test/test_git.py
index c5d871f0..540ea9f4 100644
--- a/test/test_git.py
+++ b/test/test_git.py
@@ -4,10 +4,12 @@
 #
 # This module is part of GitPython and is released under
 # the BSD License: http://www.opensource.org/licenses/bsd-license.php
+import contextlib
 import os
+import shutil
 import subprocess
 import sys
-from tempfile import TemporaryFile
+from tempfile import TemporaryDirectory, TemporaryFile
 from unittest import mock
 
 from git import Git, refresh, GitCommandError, GitCommandNotFound, Repo, cmd
@@ -20,6 +22,17 @@ import os.path as osp
 from git.compat import is_win
 
 
+@contextlib.contextmanager
+def _chdir(new_dir):
+    """Context manager to temporarily change directory. Not reentrant."""
+    old_dir = os.getcwd()
+    os.chdir(new_dir)
+    try:
+        yield
+    finally:
+        os.chdir(old_dir)
+
+
 class TestGit(TestBase):
     @classmethod
     def setUpClass(cls):
@@ -75,6 +88,23 @@ class TestGit(TestBase):
     def test_it_executes_git_to_shell_and_returns_result(self):
         self.assertRegex(self.git.execute(["git", "version"]), r"^git version [\d\.]{2}.*$")
 
+    def test_it_executes_git_not_from_cwd(self):
+        with TemporaryDirectory() as tmpdir:
+            if is_win:
+                # Copy an actual binary executable that is not git.
+                other_exe_path = os.path.join(os.getenv("WINDIR"), "system32", "hostname.exe")
+                impostor_path = os.path.join(tmpdir, "git.exe")
+                shutil.copy(other_exe_path, impostor_path)
+            else:
+                # Create a shell script that doesn't do anything.
+                impostor_path = os.path.join(tmpdir, "git")
+                with open(impostor_path, mode="w", encoding="utf-8") as file:
+                    print("#!/bin/sh", file=file)
+                os.chmod(impostor_path, 0o755)
+
+            with _chdir(tmpdir):
+                self.assertRegex(self.git.execute(["git", "version"]), r"^git version\b")
+
     def test_it_accepts_stdin(self):
         filename = fixture_path("cat_file_blob")
         with open(filename, "r") as fh:
diff --git a/test/test_repo.py b/test/test_repo.py
index 5c66aeeb..08ed13a0 100644
--- a/test/test_repo.py
+++ b/test/test_repo.py
@@ -13,7 +13,7 @@ import pathlib
 import pickle
 import sys
 import tempfile
-from unittest import mock, skipIf, SkipTest
+from unittest import mock, skipIf, SkipTest, skip
 
 import pytest
 
@@ -251,6 +251,7 @@ class TestRepo(TestBase):
                 self.fail("Raised UnicodeEncodeError")
 
     @with_rw_directory
+    @skip("the referenced repository was removed, and one needs to setup a new password controlled repo under the orgs control")
     def test_leaking_password_in_clone_logs(self, rw_dir):
         password = "fakepassword1234"
         try: