diff --git a/providers/git/src/airflow/providers/git/bundles/git.py b/providers/git/src/airflow/providers/git/bundles/git.py index 33d3c9979d5c1..0daeb30f2ab19 100644 --- a/providers/git/src/airflow/providers/git/bundles/git.py +++ b/providers/git/src/airflow/providers/git/bundles/git.py @@ -337,10 +337,10 @@ def _has_version(repo: Repo, version: str) -> bool: def _fetch_bare_repo(self): refspecs = ["+refs/heads/*:refs/heads/*", "+refs/tags/*:refs/tags/*"] - cm = nullcontext() + ssh_env_cm = nullcontext() if self.hook and (cmd := self.hook.env.get("GIT_SSH_COMMAND")): - cm = self.bare_repo.git.custom_environment(GIT_SSH_COMMAND=cmd) - with cm: + ssh_env_cm = self.bare_repo.git.custom_environment(GIT_SSH_COMMAND=cmd) + with ssh_env_cm: self.bare_repo.remotes.origin.fetch(refspecs) self.bare_repo.close() @@ -350,9 +350,16 @@ def _fetch_bare_repo(self): reraise=True, ) def _fetch_submodules(self) -> None: - self._log.info("Initializing and updating submodules", repo_path=self.repo_path) - self.repo.git.submodule("sync", "--recursive") - self.repo.git.submodule("update", "--init", "--recursive", "--jobs", "1") + # Forward full hook.env: submodule subprocesses need SSH_ASKPASS/DISPLAY/etc., not only + # GIT_SSH_COMMAND (passphrase keys); do not rely on the outer configure_hook_env os.environ alone. + hook_env = getattr(self.hook, "env", None) if self.hook else None + ssh_env_cm = nullcontext() + if isinstance(hook_env, dict) and hook_env: + ssh_env_cm = self.repo.git.custom_environment(**hook_env) + with ssh_env_cm: + self._log.info("Initializing and updating submodules", repo_path=self.repo_path) + self.repo.git.submodule("sync", "--recursive") + self.repo.git.submodule("update", "--init", "--recursive", "--jobs", "1") def refresh(self) -> None: if self.version: diff --git a/providers/git/tests/unit/git/bundles/test_git.py b/providers/git/tests/unit/git/bundles/test_git.py index 6297ab675d9bf..5025931c62b72 100644 --- a/providers/git/tests/unit/git/bundles/test_git.py +++ b/providers/git/tests/unit/git/bundles/test_git.py @@ -46,6 +46,47 @@ def bundle_temp_dir(tmp_path): GIT_DEFAULT_BRANCH = "main" + +class _GitCustomEnvContextManager: + """Minimal context manager surface for Git.custom_environment() (autospec target).""" + + def __enter__(self) -> None: + pass + + def __exit__(self, exc_type, exc_val, exc_tb) -> None: + pass + + +def _stub_git_custom_environment(**_kwargs: object): + """Stand-in for ``git.cmd.Git.custom_environment`` (``create_autospec`` target only).""" + ... # Calls are intercepted by autospec mock; implementation is unused. + + +def _stub_git_submodule(*_args: object, **_kwargs: object) -> None: + """Stand-in for GitPython submodule subcommand calls (``create_autospec`` target only).""" + ... + + +# ``_fetch_submodules`` only uses ``repo.git.custom_environment`` and ``repo.git.submodule``. +# Use ``types.SimpleNamespace`` for ``bundle.repo`` / ``repo.git`` so ``.git`` / ``.submodule`` +# are plain instance attributes — **not** MagicMock children (nested ``Repo``/``Git`` mocks +# in unittest can lack ``submodule`` on lowest-dep / CI runs). +# ``custom_environment`` / ``submodule`` use ``create_autospec`` on small stubs so typos drift +# from the GitPython-like surface are caught (Airflow test convention). + + +def _repo_and_git_stubs_for_submodule_tests(ssh_ctx_cm): + """Return (repo_stub, git_cmd_stub) for ``GitDagBundle._fetch_submodules`` unit tests.""" + custom_environment = mock.create_autospec(_stub_git_custom_environment, spec_set=True) + custom_environment.return_value = ssh_ctx_cm + submodule = mock.create_autospec(_stub_git_submodule, spec_set=True) + git_cmd = types.SimpleNamespace( + custom_environment=custom_environment, + submodule=submodule, + ) + return types.SimpleNamespace(git=git_cmd), git_cmd + + AIRFLOW_HTTPS_URL = "https://github.com/apache/airflow.git" ACCESS_TOKEN = "my_access_token" CONN_HTTPS = "my_git_conn"