This is an automated email from the ASF dual-hosted git repository.
potiuk pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/main by this push:
new bdb9a7e042b Fix breeze worktree issue (#62905)
bdb9a7e042b is described below
commit bdb9a7e042b9951160f654da37cc7c40be18bfb0
Author: Subham <[email protected]>
AuthorDate: Wed Mar 11 03:10:28 2026 +0530
Fix breeze worktree issue (#62905)
* Fix Breeze commands failing in git worktrees
* Refine worktree fix: address review feedback (YAML formatting and path
safety)
* Fix unit tests: use valid paths for git worktree detection tests
---
.../src/airflow_breeze/params/shell_params.py | 22 +++++
.../airflow_breeze/utils/docker_command_utils.py | 36 ++++---
dev/breeze/src/airflow_breeze/utils/path_utils.py | 26 +++++
dev/breeze/tests/test_git_worktree.py | 106 +++++++++++++++++++++
4 files changed, 176 insertions(+), 14 deletions(-)
diff --git a/dev/breeze/src/airflow_breeze/params/shell_params.py
b/dev/breeze/src/airflow_breeze/params/shell_params.py
index e5ab68bb0e6..f3d07d7aa9d 100644
--- a/dev/breeze/src/airflow_breeze/params/shell_params.py
+++ b/dev/breeze/src/airflow_breeze/params/shell_params.py
@@ -104,6 +104,7 @@ from airflow_breeze.utils.path_utils import (
SCRIPTS_CI_DOCKER_COMPOSE_PROVIDERS_AND_TESTS_SOURCES_PATH,
SCRIPTS_CI_DOCKER_COMPOSE_REMOVE_SOURCES_PATH,
SCRIPTS_CI_DOCKER_COMPOSE_TESTS_SOURCES_PATH,
+ get_main_git_dir_for_worktree,
)
from airflow_breeze.utils.run_utils import commit_sha, run_command
from airflow_breeze.utils.shared_options import get_forced_answer, get_verbose
@@ -387,6 +388,7 @@ class ShellParams:
compose_file_list.append(SCRIPTS_CI_DOCKER_COMPOSE_BASE_PATH)
self.add_docker_in_docker(compose_file_list)
+ self.add_git_worktree_mount(compose_file_list)
compose_file_list.extend(backend_files)
compose_file_list.append(SCRIPTS_CI_DOCKER_COMPOSE_FILES_PATH)
if os.environ.get("CI", "false") == "true":
@@ -534,6 +536,26 @@ class ShellParams:
# the /var/run/docker.sock is available. See
https://github.com/docker/for-mac/issues/6545
compose_file_list.append(SCRIPTS_CI_DOCKER_COMPOSE_DOCKER_SOCKET_PATH)
+ def add_git_worktree_mount(self, compose_file_list: list[Path]):
+ main_git_directory = get_main_git_dir_for_worktree()
+ if main_git_directory:
+ get_console().print(
+ f"[info]Detected git worktree. Mounting main git directory:
{main_git_directory}[/]"
+ )
+ generated_compose_file = SCRIPTS_CI_DOCKER_COMPOSE_PATH /
"_generated_git_worktree_mount.yml"
+ generated_compose_file.write_text(
+ f"""---
+services:
+ airflow:
+ volumes:
+ - type: bind
+ source: "{main_git_directory}"
+ target: "{main_git_directory}"
+ read_only: true
+"""
+ )
+ compose_file_list.append(generated_compose_file)
+
@cached_property
def rootless_docker(self) -> bool:
return is_docker_rootless()
diff --git a/dev/breeze/src/airflow_breeze/utils/docker_command_utils.py
b/dev/breeze/src/airflow_breeze/utils/docker_command_utils.py
index 574d8e0d8f0..0ac7eb02c37 100644
--- a/dev/breeze/src/airflow_breeze/utils/docker_command_utils.py
+++ b/dev/breeze/src/airflow_breeze/utils/docker_command_utils.py
@@ -37,6 +37,7 @@ from airflow_breeze.utils.path_utils import (
SCRIPTS_DOCKER_PATH,
cleanup_python_generated_files,
create_mypy_volume_if_needed,
+ get_main_git_dir_for_worktree,
)
from airflow_breeze.utils.shared_options import get_verbose
from airflow_breeze.utils.visuals import ASCIIART, ASCIIART_STYLE, CHEATSHEET,
CHEATSHEET_STYLE
@@ -672,21 +673,28 @@ def fix_ownership_using_docker(quiet: bool = True):
"run",
"-v",
f"{AIRFLOW_ROOT_PATH}:/opt/airflow/",
- "-e",
- f"HOST_OS={get_host_os()}",
- "-e",
- f"HOST_USER_ID={get_host_user_id()}",
- "-e",
- f"HOST_GROUP_ID={get_host_group_id()}",
- "-e",
- f"VERBOSE={str(get_verbose()).lower()}",
- "-e",
- f"DOCKER_IS_ROOTLESS={is_docker_rootless()}",
- "--rm",
- "-t",
- OWNERSHIP_CLEANUP_DOCKER_TAG,
- "/opt/airflow/scripts/in_container/run_fix_ownership.py",
]
+ main_git_directory = get_main_git_dir_for_worktree()
+ if main_git_directory:
+ cmd.extend(["-v", f"{main_git_directory}:{main_git_directory}:ro"])
+ cmd.extend(
+ [
+ "-e",
+ f"HOST_OS={get_host_os()}",
+ "-e",
+ f"HOST_USER_ID={get_host_user_id()}",
+ "-e",
+ f"HOST_GROUP_ID={get_host_group_id()}",
+ "-e",
+ f"VERBOSE={str(get_verbose()).lower()}",
+ "-e",
+ f"DOCKER_IS_ROOTLESS={is_docker_rootless()}",
+ "--rm",
+ "-t",
+ OWNERSHIP_CLEANUP_DOCKER_TAG,
+ "/opt/airflow/scripts/in_container/run_fix_ownership.py",
+ ]
+ )
run_command(cmd, text=True, check=False, quiet=quiet)
diff --git a/dev/breeze/src/airflow_breeze/utils/path_utils.py
b/dev/breeze/src/airflow_breeze/utils/path_utils.py
index 4aa68930ed9..819cf22b072 100644
--- a/dev/breeze/src/airflow_breeze/utils/path_utils.py
+++ b/dev/breeze/src/airflow_breeze/utils/path_utils.py
@@ -435,3 +435,29 @@ def cleanup_python_generated_files():
get_console().print("You can also remove those files manually
using sudo.")
if get_verbose():
get_console().print("[info]Cleaned")
+
+
+def get_main_git_dir_for_worktree() -> Path | None:
+ """
+ Detect if we are in a git worktree and return the main repository's .git
directory.
+
+ Git worktrees store a ``.git`` *file* (not a directory) containing a
``gitdir:`` reference
+ pointing to ``<main-repo>/.git/worktrees/<name>``. This helper resolves
that reference
+ (handling both absolute and relative paths) and returns the main ``.git``
directory
+ (i.e. the grandparent of the ``gitdir`` path).
+
+ :return: Absolute path to the main repository's ``.git`` directory, or
``None``
+ if the current checkout is not a worktree.
+ """
+ git_path = AIRFLOW_ROOT_PATH / ".git"
+ if git_path.is_file():
+ git_content = git_path.read_text().strip()
+ if git_content.startswith("gitdir:"):
+ gitdir = Path(git_content.removeprefix("gitdir:").strip())
+ if not gitdir.is_absolute():
+ gitdir = (AIRFLOW_ROOT_PATH / gitdir).resolve()
+ # gitdir points to <main-repo>/.git/worktrees/<name>
+ # .parent.parent gives us <main-repo>/.git
+ main_git_dir = gitdir.parent.parent
+ return main_git_dir if main_git_dir.is_dir() else None
+ return None
diff --git a/dev/breeze/tests/test_git_worktree.py
b/dev/breeze/tests/test_git_worktree.py
new file mode 100644
index 00000000000..a0c9d439bd2
--- /dev/null
+++ b/dev/breeze/tests/test_git_worktree.py
@@ -0,0 +1,106 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+from pathlib import Path
+from unittest import mock
+
+import pytest
+
+from airflow_breeze.utils.path_utils import get_main_git_dir_for_worktree
+
+
+class TestGetMainGitDirForWorktree:
+ """Tests for get_main_git_dir_for_worktree detection."""
+
+ def test_returns_none_when_dot_git_is_directory(self, tmp_path):
+ """Standard clone: .git is a directory, not a worktree."""
+ git_dir = tmp_path / ".git"
+ git_dir.mkdir()
+ with mock.patch("airflow_breeze.utils.path_utils.AIRFLOW_ROOT_PATH",
tmp_path):
+ assert get_main_git_dir_for_worktree() is None
+
+ def test_returns_none_when_dot_git_missing(self, tmp_path):
+ """No .git at all — not a git repo."""
+ with mock.patch("airflow_breeze.utils.path_utils.AIRFLOW_ROOT_PATH",
tmp_path):
+ assert get_main_git_dir_for_worktree() is None
+
+ def test_returns_none_when_dot_git_file_without_gitdir_prefix(self,
tmp_path):
+ """.git file exists but does not start with 'gitdir: '."""
+ git_file = tmp_path / ".git"
+ git_file.write_text("something unexpected\n")
+ with mock.patch("airflow_breeze.utils.path_utils.AIRFLOW_ROOT_PATH",
tmp_path):
+ assert get_main_git_dir_for_worktree() is None
+
+ def test_absolute_gitdir_path(self, tmp_path):
+ """Worktree with an absolute gitdir path resolves to the main .git
directory."""
+ # Simulate: /main-repo/.git/worktrees/my-worktree
+ main_repo = tmp_path / "main-repo"
+ main_git = main_repo / ".git"
+ worktree_gitdir = main_git / "worktrees" / "my-worktree"
+ worktree_gitdir.mkdir(parents=True)
+
+ worktree_dir = tmp_path / "my-worktree"
+ worktree_dir.mkdir()
+ (worktree_dir / ".git").write_text(f"gitdir: {worktree_gitdir}\n")
+
+ with mock.patch("airflow_breeze.utils.path_utils.AIRFLOW_ROOT_PATH",
worktree_dir):
+ result = get_main_git_dir_for_worktree()
+ assert result is not None
+ assert result == main_git
+
+ def test_relative_gitdir_path(self, tmp_path):
+ """Worktree with a relative gitdir path is resolved correctly."""
+ # Simulate: main-repo/.git/worktrees/my-worktree
+ main_repo = tmp_path / "main-repo"
+ main_git = main_repo / ".git"
+ worktree_gitdir = main_git / "worktrees" / "my-worktree"
+ worktree_gitdir.mkdir(parents=True)
+
+ worktree_dir = tmp_path / "my-worktree"
+ worktree_dir.mkdir()
+ # Write a relative path from worktree_dir to worktree_gitdir
+ relative_gitdir = Path("../main-repo/.git/worktrees/my-worktree")
+ (worktree_dir / ".git").write_text(f"gitdir: {relative_gitdir}\n")
+
+ with mock.patch("airflow_breeze.utils.path_utils.AIRFLOW_ROOT_PATH",
worktree_dir):
+ result = get_main_git_dir_for_worktree()
+ assert result is not None
+ assert result.resolve() == main_git.resolve()
+
+ @pytest.mark.parametrize(
+ "gitdir_content",
+ [
+ "gitdir: {path}/worktrees/wt\n",
+ "gitdir: {path}/worktrees/wt",
+ "gitdir: {path}/worktrees/wt \n",
+ ],
+ ids=["trailing-newline", "no-trailing-newline", "extra-whitespace"],
+ )
+ def test_strips_whitespace_from_gitdir(self, tmp_path, gitdir_content):
+ """Whitespace and trailing newlines are stripped from the gitdir
content."""
+ main_repo = tmp_path / "main-repo"
+ main_git = main_repo / ".git"
+ (main_git / "worktrees" / "wt").mkdir(parents=True)
+
+ content = gitdir_content.format(path=main_git)
+ (tmp_path / ".git").write_text(content)
+
+ with mock.patch("airflow_breeze.utils.path_utils.AIRFLOW_ROOT_PATH",
tmp_path):
+ result = get_main_git_dir_for_worktree()
+ assert result is not None
+ assert result == main_git