Browse Source

Support matchers in includeIf (#1624)

Jelmer Vernooij 1 tháng trước cách đây
mục cha
commit
124b07f130
4 tập tin đã thay đổi với 322 bổ sung170 xóa
  1. 111 62
      dulwich/config.py
  2. 83 9
      dulwich/repo.py
  3. 49 99
      tests/test_config.py
  4. 79 0
      tests/test_repository.py

+ 111 - 62
dulwich/config.py

@@ -27,6 +27,7 @@ Todo:
 
 import logging
 import os
+import re
 import sys
 from collections.abc import Iterable, Iterator, KeysView, MutableMapping
 from contextlib import suppress
@@ -47,6 +48,10 @@ logger = logging.getLogger(__name__)
 # Type for file opener callback
 FileOpener = Callable[[Union[str, os.PathLike]], BinaryIO]
 
+# Type for includeIf condition matcher
+# Takes the condition value (e.g., "main" for onbranch:main) and returns bool
+ConditionMatcher = Callable[[str], bool]
+
 # Security limits for include files
 MAX_INCLUDE_FILE_SIZE = 1024 * 1024  # 1MB max for included config files
 DEFAULT_MAX_INCLUDE_DEPTH = 10  # Maximum recursion depth for includes
@@ -108,6 +113,29 @@ def _match_gitdir_pattern(
         return path_str == pattern_str
 
 
+def match_glob_pattern(value: str, pattern: str) -> bool:
+    """Match a value against a glob pattern.
+
+    Supports simple glob patterns like * and **.
+
+    Raises:
+        ValueError: If the pattern is invalid
+    """
+    # Convert glob pattern to regex
+    pattern_escaped = re.escape(pattern)
+    # Replace escaped \*\* with .* (match anything)
+    pattern_escaped = pattern_escaped.replace(r"\*\*", ".*")
+    # Replace escaped \* with [^/]* (match anything except /)
+    pattern_escaped = pattern_escaped.replace(r"\*", "[^/]*")
+    # Anchor the pattern
+    pattern_regex = f"^{pattern_escaped}$"
+
+    try:
+        return bool(re.match(pattern_regex, value))
+    except re.error as e:
+        raise ValueError(f"Invalid glob pattern {pattern!r}: {e}")
+
+
 def lower_key(key):
     if isinstance(key, (bytes, str)):
         return key.lower()
@@ -672,22 +700,22 @@ class ConfigFile(ConfigDict):
         f: BinaryIO,
         *,
         config_dir: Optional[str] = None,
-        repo_dir: Optional[str] = None,
         included_paths: Optional[set[str]] = None,
         include_depth: int = 0,
         max_include_depth: int = DEFAULT_MAX_INCLUDE_DEPTH,
         file_opener: Optional[FileOpener] = None,
+        condition_matchers: Optional[dict[str, ConditionMatcher]] = None,
     ) -> "ConfigFile":
         """Read configuration from a file-like object.
 
         Args:
             f: File-like object to read from
             config_dir: Directory containing the config file (for relative includes)
-            repo_dir: Repository directory (for gitdir conditions)
             included_paths: Set of already included paths (to prevent cycles)
             include_depth: Current include depth (to prevent infinite recursion)
             max_include_depth: Maximum allowed include depth
             file_opener: Optional callback to open included files
+            condition_matchers: Optional dict of condition matchers for includeIf
         """
         if include_depth > max_include_depth:
             # Prevent excessive recursion
@@ -736,10 +764,10 @@ class ConfigFile(ConfigDict):
                         setting,
                         value,
                         config_dir=config_dir,
-                        repo_dir=repo_dir,
                         include_depth=include_depth,
                         max_include_depth=max_include_depth,
                         file_opener=file_opener,
+                        condition_matchers=condition_matchers,
                     )
 
                     setting = None
@@ -761,10 +789,10 @@ class ConfigFile(ConfigDict):
                         setting,
                         value,
                         config_dir=config_dir,
-                        repo_dir=repo_dir,
                         include_depth=include_depth,
                         max_include_depth=max_include_depth,
                         file_opener=file_opener,
+                        condition_matchers=condition_matchers,
                     )
 
                     continuation = None
@@ -778,10 +806,10 @@ class ConfigFile(ConfigDict):
         value: bytes,
         *,
         config_dir: Optional[str],
-        repo_dir: Optional[str],
         include_depth: int,
         max_include_depth: int,
         file_opener: Optional[FileOpener],
+        condition_matchers: Optional[dict[str, ConditionMatcher]],
     ) -> None:
         """Handle include/includeIf directives during config parsing."""
         if (
@@ -796,10 +824,10 @@ class ConfigFile(ConfigDict):
                 section,
                 value,
                 config_dir=config_dir,
-                repo_dir=repo_dir,
                 include_depth=include_depth,
                 max_include_depth=max_include_depth,
                 file_opener=file_opener,
+                condition_matchers=condition_matchers,
             )
 
     def _process_include(
@@ -808,10 +836,10 @@ class ConfigFile(ConfigDict):
         path_value: bytes,
         *,
         config_dir: Optional[str],
-        repo_dir: Optional[str],
         include_depth: int,
         max_include_depth: int,
         file_opener: Optional[FileOpener],
+        condition_matchers: Optional[dict[str, ConditionMatcher]],
     ) -> None:
         """Process an include or includeIf directive."""
         path_str = path_value.decode(self.encoding, errors="replace")
@@ -819,7 +847,9 @@ class ConfigFile(ConfigDict):
         # Handle includeIf conditions
         if len(section) > 1 and section[0].lower() == b"includeif":
             condition = section[1].decode(self.encoding, errors="replace")
-            if not self._evaluate_includeif_condition(condition, repo_dir):
+            if not self._evaluate_includeif_condition(
+                condition, config_dir, condition_matchers
+            ):
                 return
 
         # Resolve the include path
@@ -861,11 +891,11 @@ class ConfigFile(ConfigDict):
                 included_config = ConfigFile.from_file(
                     included_file,
                     config_dir=os.path.dirname(include_path),
-                    repo_dir=repo_dir,
                     included_paths=self._included_paths,
                     include_depth=include_depth + 1,
                     max_include_depth=max_include_depth,
                     file_opener=file_opener,
+                    condition_matchers=condition_matchers,
                 )
 
                 # Merge the included configuration
@@ -893,89 +923,108 @@ class ConfigFile(ConfigDict):
         return path
 
     def _evaluate_includeif_condition(
-        self, condition: str, repo_dir: Optional[str]
+        self,
+        condition: str,
+        config_dir: Optional[str] = None,
+        condition_matchers: Optional[dict[str, ConditionMatcher]] = None,
     ) -> bool:
         """Evaluate an includeIf condition."""
-        if condition.startswith("gitdir:"):
-            return self._evaluate_gitdir_condition(condition[7:], repo_dir)
-        elif condition.startswith("gitdir/i:"):
-            return self._evaluate_gitdir_condition(
-                condition[9:], repo_dir, case_sensitive=False
-            )
-        elif condition.startswith("hasconfig:"):
-            # hasconfig conditions require special handling and access to the full config
-            # For now, we'll skip these as they require more complex implementation
-            return False
+        # Try custom matchers first if provided
+        if condition_matchers:
+            for prefix, matcher in condition_matchers.items():
+                if condition.startswith(prefix):
+                    return matcher(condition[len(prefix) :])
+
+        # Fall back to built-in matchers
+        if condition.startswith("hasconfig:"):
+            return self._evaluate_hasconfig_condition(condition[10:])
         else:
             # Unknown condition type - log and ignore (Git behavior)
             logger.debug("Unknown includeIf condition: %r", condition)
             return False
 
-    def _evaluate_gitdir_condition(
-        self, pattern: str, repo_dir: Optional[str], case_sensitive: bool = True
-    ) -> bool:
-        """Evaluate a gitdir condition using simplified pattern matching."""
-        if not repo_dir:
-            return False
-
-        # Skip relative patterns for now (would need config file path)
-        if pattern.startswith("./"):
-            return False
+    def _evaluate_hasconfig_condition(self, condition: str) -> bool:
+        """Evaluate a hasconfig condition.
 
-        # Normalize repository path
-        try:
-            repo_path = str(Path(repo_dir).resolve())
-        except (OSError, ValueError) as e:
-            logger.debug("Invalid repository path %r: %s", repo_dir, e)
+        Format: hasconfig:config.key:pattern
+        Example: hasconfig:remote.*.url:ssh://org-*@github.com/**
+        """
+        # Split on the first colon to separate config key from pattern
+        parts = condition.split(":", 1)
+        if len(parts) != 2:
+            logger.debug("Invalid hasconfig condition format: %r", condition)
             return False
 
-        # Expand ~ in pattern and normalize
-        pattern = os.path.expanduser(pattern)
-        pattern = self._normalize_gitdir_pattern(pattern)
+        config_key, pattern = parts
 
-        # Use simple pattern matching for gitdir conditions
-        pattern_bytes = pattern.encode("utf-8", errors="replace")
-        repo_path_bytes = repo_path.encode("utf-8", errors="replace")
+        # Parse the config key to get section and name
+        key_parts = config_key.split(".", 2)
+        if len(key_parts) < 2:
+            logger.debug("Invalid hasconfig config key: %r", config_key)
+            return False
 
-        return _match_gitdir_pattern(
-            repo_path_bytes, pattern_bytes, ignorecase=not case_sensitive
-        )
+        # Handle wildcards in section names (e.g., remote.*)
+        if len(key_parts) == 3 and key_parts[1] == "*":
+            # Match any subsection
+            section_prefix = key_parts[0].encode(self.encoding)
+            name = key_parts[2].encode(self.encoding)
+
+            # Check all sections that match the pattern
+            for section in self.sections():
+                if len(section) == 2 and section[0] == section_prefix:
+                    try:
+                        values = list(self.get_multivar(section, name))
+                        for value in values:
+                            if self._match_hasconfig_pattern(value, pattern):
+                                return True
+                    except KeyError:
+                        continue
+        else:
+            # Direct section lookup
+            if len(key_parts) == 2:
+                section = (key_parts[0].encode(self.encoding),)
+                name = key_parts[1].encode(self.encoding)
+            else:
+                section = (
+                    key_parts[0].encode(self.encoding),
+                    key_parts[1].encode(self.encoding),
+                )
+                name = key_parts[2].encode(self.encoding)
 
-    def _normalize_gitdir_pattern(self, pattern: str) -> str:
-        """Normalize a gitdir pattern following Git's rules."""
-        # Normalize path separators to forward slashes
-        pattern = pattern.replace("\\", "/")
+            try:
+                values = list(self.get_multivar(section, name))
+                for value in values:
+                    if self._match_hasconfig_pattern(value, pattern):
+                        return True
+            except KeyError:
+                pass
 
-        # If pattern doesn't start with ~/, ./, /, drive letter (Windows), or **, prepend **/
-        if not pattern.startswith(("~/", "./", "/", "**")):
-            # Check for Windows absolute path (e.g., C:/, D:/)
-            if len(pattern) >= 2 and pattern[1] == ":":
-                pass  # Don't prepend **/ for Windows absolute paths
-            else:
-                pattern = "**/" + pattern
+        return False
 
-        # If pattern ends with /, append **
-        if pattern.endswith("/"):
-            pattern = pattern + "**"
+    def _match_hasconfig_pattern(self, value: bytes, pattern: str) -> bool:
+        """Match a config value against a hasconfig pattern.
 
-        return pattern
+        Supports simple glob patterns like * and **.
+        """
+        value_str = value.decode(self.encoding, errors="replace")
+        return match_glob_pattern(value_str, pattern)
 
     @classmethod
     def from_path(
         cls,
         path: Union[str, os.PathLike],
         *,
-        repo_dir: Optional[str] = None,
         max_include_depth: int = DEFAULT_MAX_INCLUDE_DEPTH,
         file_opener: Optional[FileOpener] = None,
+        condition_matchers: Optional[dict[str, ConditionMatcher]] = None,
     ) -> "ConfigFile":
         """Read configuration from a file on disk.
 
         Args:
             path: Path to the configuration file
-            repo_dir: Repository directory (for gitdir conditions in includeIf)
             max_include_depth: Maximum allowed include depth
             file_opener: Optional callback to open included files
+            condition_matchers: Optional dict of condition matchers for includeIf
         """
         abs_path = os.fspath(path)
         config_dir = os.path.dirname(abs_path)
@@ -992,9 +1041,9 @@ class ConfigFile(ConfigDict):
             ret = cls.from_file(
                 f,
                 config_dir=config_dir,
-                repo_dir=repo_dir,
                 max_include_depth=max_include_depth,
                 file_opener=file_opener,
+                condition_matchers=condition_matchers,
             )
             ret.path = abs_path
             return ret

+ 83 - 9
dulwich/repo.py

@@ -49,7 +49,7 @@ if TYPE_CHECKING:
     # There are no circular imports here, but we try to defer imports as long
     # as possible to reduce start-up time for anything that doesn't need
     # these imports.
-    from .config import ConfigFile, StackedConfig
+    from .config import ConditionMatcher, ConfigFile, StackedConfig
     from .index import Index
     from .notes import Notes
 
@@ -1241,6 +1241,12 @@ class Repo(BaseRepo):
         else:
             self._commondir = self._controldir
         self.path = root
+
+        # Initialize refs early so they're available for config condition matchers
+        self.refs = DiskRefsContainer(
+            self.commondir(), self._controldir, logger=self._write_reflog
+        )
+
         config = self.get_config()
         try:
             repository_format_version = config.get("core", "repositoryformatversion")
@@ -1263,10 +1269,7 @@ class Repo(BaseRepo):
             object_store = DiskObjectStore.from_config(
                 os.path.join(self.commondir(), OBJECTDIR), config
             )
-        refs = DiskRefsContainer(
-            self.commondir(), self._controldir, logger=self._write_reflog
-        )
-        BaseRepo.__init__(self, object_store, refs)
+        BaseRepo.__init__(self, object_store, self.refs)
 
         self._graftpoints = {}
         graft_file = self.get_named_file(
@@ -1727,13 +1730,83 @@ class Repo(BaseRepo):
             symlink_fn=symlink_fn,
         )
 
+    def _get_config_condition_matchers(self) -> dict[str, "ConditionMatcher"]:
+        """Get condition matchers for includeIf conditions.
+
+        Returns a dict of condition prefix to matcher function.
+        """
+        from pathlib import Path
+
+        from .config import ConditionMatcher, match_glob_pattern
+
+        # Add gitdir matchers
+        def match_gitdir(pattern: str, case_sensitive: bool = True) -> bool:
+            # Handle relative patterns (starting with ./)
+            if pattern.startswith("./"):
+                # Can't handle relative patterns without config directory context
+                return False
+
+            # Normalize repository path
+            try:
+                repo_path = str(Path(self._controldir).resolve())
+            except (OSError, ValueError):
+                return False
+
+            # Expand ~ in pattern and normalize
+            pattern = os.path.expanduser(pattern)
+
+            # Normalize pattern following Git's rules
+            pattern = pattern.replace("\\", "/")
+            if not pattern.startswith(("~/", "./", "/", "**")):
+                # Check for Windows absolute path
+                if len(pattern) >= 2 and pattern[1] == ":":
+                    pass
+                else:
+                    pattern = "**/" + pattern
+            if pattern.endswith("/"):
+                pattern = pattern + "**"
+
+            # Use the existing _match_gitdir_pattern function
+            from .config import _match_gitdir_pattern
+
+            pattern_bytes = pattern.encode("utf-8", errors="replace")
+            repo_path_bytes = repo_path.encode("utf-8", errors="replace")
+
+            return _match_gitdir_pattern(
+                repo_path_bytes, pattern_bytes, ignorecase=not case_sensitive
+            )
+
+        # Add onbranch matcher
+        def match_onbranch(pattern: str) -> bool:
+            try:
+                # Get the current branch using refs
+                ref_chain, _ = self.refs.follow(b"HEAD")
+                head_ref = ref_chain[-1]  # Get the final resolved ref
+            except KeyError:
+                pass
+            else:
+                if head_ref and head_ref.startswith(b"refs/heads/"):
+                    # Extract branch name from ref
+                    branch = head_ref[11:].decode("utf-8", errors="replace")
+                    return match_glob_pattern(branch, pattern)
+            return False
+
+        matchers: dict[str, ConditionMatcher] = {
+            "onbranch:": match_onbranch,
+            "gitdir:": lambda pattern: match_gitdir(pattern, True),
+            "gitdir/i:": lambda pattern: match_gitdir(pattern, False),
+        }
+
+        return matchers
+
     def get_worktree_config(self) -> "ConfigFile":
         from .config import ConfigFile
 
         path = os.path.join(self.commondir(), "config.worktree")
         try:
-            # Pass the git directory (not working tree) for includeIf gitdir conditions
-            return ConfigFile.from_path(path, repo_dir=self._controldir)
+            # Pass condition matchers for includeIf evaluation
+            condition_matchers = self._get_config_condition_matchers()
+            return ConfigFile.from_path(path, condition_matchers=condition_matchers)
         except FileNotFoundError:
             cf = ConfigFile()
             cf.path = path
@@ -1748,8 +1821,9 @@ class Repo(BaseRepo):
 
         path = os.path.join(self._commondir, "config")
         try:
-            # Pass the git directory (not working tree) for includeIf gitdir conditions
-            return ConfigFile.from_path(path, repo_dir=self._controldir)
+            # Pass condition matchers for includeIf evaluation
+            condition_matchers = self._get_config_condition_matchers()
+            return ConfigFile.from_path(path, condition_matchers=condition_matchers)
         except FileNotFoundError:
             ret = ConfigFile()
             ret.path = path

+ 49 - 99
tests/test_config.py

@@ -426,80 +426,79 @@ who\"
             cf = ConfigFile.from_path(main_path)
             self.assertEqual(b"true", cf.get((b"core",), b"bare"))
 
-    def test_includeif_gitdir_match(self) -> None:
-        """Test includeIf with gitdir condition that matches."""
+    def test_includeif_hasconfig(self) -> None:
+        """Test includeIf with hasconfig conditions."""
         with tempfile.TemporaryDirectory() as tmpdir:
-            repo_dir = os.path.join(tmpdir, "myrepo")
-            os.makedirs(repo_dir)
-            # Use realpath to resolve any symlinks (important on macOS)
-            repo_dir = os.path.realpath(repo_dir)
-
             # Create included config file
-            included_path = os.path.join(tmpdir, "work.config")
-            with open(included_path, "wb") as f:
-                f.write(b"[user]\n    email = work@example.com\n")
+            work_included_path = os.path.join(tmpdir, "work.config")
+            with open(work_included_path, "wb") as f:
+                f.write(b"[user]\n    email = work@company.com\n")
 
-            # Create main config with includeIf
+            personal_included_path = os.path.join(tmpdir, "personal.config")
+            with open(personal_included_path, "wb") as f:
+                f.write(b"[user]\n    email = personal@example.com\n")
+
+            # Create main config with hasconfig conditions
             main_path = os.path.join(tmpdir, "main.config")
             with open(main_path, "wb") as f:
                 f.write(
-                    f'[includeIf "gitdir:{repo_dir}/"]\n    path = work.config\n'.encode()
+                    b'[remote "origin"]\n'
+                    b"    url = ssh://org-work@github.com/company/project\n"
+                    b'[includeIf "hasconfig:remote.*.url:ssh://org-*@github.com/**"]\n'
+                    b"    path = work.config\n"
+                    b'[includeIf "hasconfig:remote.*.url:https://github.com/opensource/**"]\n'
+                    b"    path = personal.config\n"
                 )
 
-            # Load with matching repo_dir
-            cf = ConfigFile.from_path(main_path, repo_dir=repo_dir)
-            self.assertEqual(b"work@example.com", cf.get((b"user",), b"email"))
+            # Load config - should match the work config due to org-work remote
+            # The second condition won't match since url doesn't have /opensource/ path
+            cf = ConfigFile.from_path(main_path)
+            self.assertEqual(b"work@company.com", cf.get((b"user",), b"email"))
 
-    def test_includeif_gitdir_no_match(self) -> None:
-        """Test includeIf with gitdir condition that doesn't match."""
+    def test_includeif_hasconfig_wildcard(self) -> None:
+        """Test includeIf hasconfig with wildcard patterns."""
         with tempfile.TemporaryDirectory() as tmpdir:
-            repo_dir = os.path.join(tmpdir, "myrepo")
-            other_dir = os.path.join(tmpdir, "other")
-            os.makedirs(repo_dir)
-            os.makedirs(other_dir)
-            # Use realpath to resolve any symlinks (important on macOS)
-            repo_dir = os.path.realpath(repo_dir)
-            other_dir = os.path.realpath(other_dir)
-
-            # Create included config file
-            included_path = os.path.join(tmpdir, "work.config")
+            # Create included config
+            included_path = os.path.join(tmpdir, "included.config")
             with open(included_path, "wb") as f:
-                f.write(b"[user]\n    email = work@example.com\n")
+                f.write(b"[user]\n    name = IncludedUser\n")
 
-            # Create main config with includeIf
+            # Create main config with hasconfig condition using wildcards
             main_path = os.path.join(tmpdir, "main.config")
             with open(main_path, "wb") as f:
                 f.write(
-                    f'[includeIf "gitdir:{repo_dir}/"]\n    path = work.config\n'.encode()
+                    b"[core]\n"
+                    b"    autocrlf = true\n"
+                    b'[includeIf "hasconfig:core.autocrlf:true"]\n'
+                    b"    path = included.config\n"
                 )
 
-            # Load with non-matching repo_dir
-            cf = ConfigFile.from_path(main_path, repo_dir=other_dir)
-            with self.assertRaises(KeyError):
-                cf.get((b"user",), b"email")
+            # Load config - should include based on core.autocrlf value
+            cf = ConfigFile.from_path(main_path)
+            self.assertEqual(b"IncludedUser", cf.get((b"user",), b"name"))
 
-    def test_includeif_gitdir_pattern(self) -> None:
-        """Test includeIf with gitdir pattern matching."""
+    def test_includeif_hasconfig_no_match(self) -> None:
+        """Test includeIf hasconfig when condition doesn't match."""
         with tempfile.TemporaryDirectory() as tmpdir:
-            # Use realpath to resolve any symlinks
-            tmpdir = os.path.realpath(tmpdir)
-            work_dir = os.path.join(tmpdir, "work", "project1")
-            os.makedirs(work_dir)
-
-            # Create included config file
-            included_path = os.path.join(tmpdir, "work.config")
+            # Create included config
+            included_path = os.path.join(tmpdir, "included.config")
             with open(included_path, "wb") as f:
-                f.write(b"[user]\n    email = work@company.com\n")
+                f.write(b"[user]\n    name = IncludedUser\n")
 
-            # Create main config with pattern
+            # Create main config with non-matching hasconfig condition
             main_path = os.path.join(tmpdir, "main.config")
             with open(main_path, "wb") as f:
-                # Pattern that should match any repo under work/
-                f.write(b'[includeIf "gitdir:work/**"]\n    path = work.config\n')
+                f.write(
+                    b"[core]\n"
+                    b"    autocrlf = false\n"
+                    b'[includeIf "hasconfig:core.autocrlf:true"]\n'
+                    b"    path = included.config\n"
+                )
 
-            # Load with matching pattern
-            cf = ConfigFile.from_path(main_path, repo_dir=work_dir)
-            self.assertEqual(b"work@company.com", cf.get((b"user",), b"email"))
+            # Load config - should NOT include since condition doesn't match
+            cf = ConfigFile.from_path(main_path)
+            with self.assertRaises(KeyError):
+                cf.get((b"user",), b"name")
 
     def test_include_circular(self) -> None:
         """Test that circular includes are handled properly."""
@@ -704,55 +703,6 @@ who\"
         finally:
             logger.removeHandler(handler)
 
-    def test_includeif_with_custom_file_opener(self) -> None:
-        """Test includeIf functionality with custom file opener."""
-        with tempfile.TemporaryDirectory() as tmpdir:
-            # Use realpath to resolve any symlinks
-            tmpdir = os.path.realpath(tmpdir)
-            repo_dir = os.path.join(tmpdir, "work", "project", ".git")
-            os.makedirs(repo_dir, exist_ok=True)
-
-            # Create config files
-            work_config_path = os.path.join(tmpdir, "work.config")
-            with open(work_config_path, "wb") as f:
-                f.write(b"[user]\n    email = work@company.com\n")
-
-            personal_config_path = os.path.join(tmpdir, "personal.config")
-            with open(personal_config_path, "wb") as f:
-                f.write(b"[user]\n    email = personal@home.com\n")
-
-            main_path = os.path.join(tmpdir, "main.config")
-            with open(main_path, "wb") as f:
-                f.write(b"[user]\n    name = Test User\n")
-                f.write(b'[includeIf "gitdir:**/work/**"]\n')
-                escaped_work_path = work_config_path.replace("\\", "\\\\")
-                f.write(f"    path = {escaped_work_path}\n".encode())
-                f.write(b'[includeIf "gitdir:**/personal/**"]\n')
-                escaped_personal_path = personal_config_path.replace("\\", "\\\\")
-                f.write(f"    path = {escaped_personal_path}\n".encode())
-
-            # Track which files were opened
-            opened_files = []
-
-            def tracking_file_opener(path):
-                path_str = os.fspath(path)
-                opened_files.append(path_str)
-                return open(path_str, "rb")
-
-            # Load config with tracking file opener
-            cf = ConfigFile.from_path(
-                main_path, repo_dir=repo_dir, file_opener=tracking_file_opener
-            )
-
-            # Check results
-            self.assertEqual(b"Test User", cf.get((b"user",), b"name"))
-            self.assertEqual(b"work@company.com", cf.get((b"user",), b"email"))
-
-            # Verify that only the matching includeIf file was opened
-            self.assertIn(main_path, opened_files)
-            self.assertIn(work_config_path, opened_files)
-            self.assertNotIn(personal_config_path, opened_files)
-
     def test_custom_file_opener_with_include_depth(self) -> None:
         """Test that custom file opener is passed through include chain."""
         with tempfile.TemporaryDirectory() as tmpdir:

+ 79 - 0
tests/test_repository.py

@@ -1837,3 +1837,82 @@ class RepoConfigIncludeIfTests(TestCase):
             config = r.get_config()
             self.assertEqual(b"true", config.get((b"receive",), b"denyNonFastForwards"))
             r.close()
+
+    def test_repo_config_includeif_hasconfig(self) -> None:
+        """Test includeIf hasconfig conditions in repository config."""
+        import tempfile
+
+        from dulwich.repo import Repo
+
+        with tempfile.TemporaryDirectory() as tmpdir:
+            # Create a repository
+            repo_path = os.path.join(tmpdir, "myrepo")
+            r = Repo.init(repo_path, mkdir=True)
+
+            # Create an included config file
+            included_path = os.path.join(tmpdir, "work.config")
+            with open(included_path, "wb") as f:
+                f.write(b"[user]\n    name = WorkUser\n")
+
+            # Add a remote and includeIf hasconfig to the repo config
+            config_path = os.path.join(repo_path, ".git", "config")
+            with open(config_path, "ab") as f:
+                f.write(b'\n[remote "origin"]\n')
+                f.write(b"    url = ssh://org-work@github.com/company/project\n")
+                f.write(
+                    b'[includeIf "hasconfig:remote.*.url:ssh://org-*@github.com/**"]\n'
+                )
+                escaped_path = included_path.replace("\\", "\\\\")
+                f.write(f"    path = {escaped_path}\n".encode())
+
+            # Close and reopen to reload config
+            r.close()
+            r = Repo(repo_path)
+
+            # Check if include was processed
+            config = r.get_config()
+            self.assertEqual(b"WorkUser", config.get((b"user",), b"name"))
+            r.close()
+
+    def test_repo_config_includeif_onbranch(self) -> None:
+        """Test includeIf onbranch conditions in repository config."""
+        import tempfile
+
+        from dulwich.repo import Repo
+
+        with tempfile.TemporaryDirectory() as tmpdir:
+            # Create a repository
+            repo_path = os.path.join(tmpdir, "myrepo")
+            r = Repo.init(repo_path, mkdir=True)
+
+            # Create HEAD pointing to main branch
+            refs_heads_dir = os.path.join(repo_path, ".git", "refs", "heads")
+            os.makedirs(refs_heads_dir, exist_ok=True)
+            main_ref_path = os.path.join(refs_heads_dir, "main")
+            with open(main_ref_path, "wb") as f:
+                f.write(b"0123456789012345678901234567890123456789\n")
+
+            head_path = os.path.join(repo_path, ".git", "HEAD")
+            with open(head_path, "wb") as f:
+                f.write(b"ref: refs/heads/main\n")
+
+            # Create an included config file
+            included_path = os.path.join(tmpdir, "main.config")
+            with open(included_path, "wb") as f:
+                f.write(b"[core]\n    autocrlf = true\n")
+
+            # Add includeIf onbranch to the repo config
+            config_path = os.path.join(repo_path, ".git", "config")
+            with open(config_path, "ab") as f:
+                f.write(b'\n[includeIf "onbranch:main"]\n')
+                escaped_path = included_path.replace("\\", "\\\\")
+                f.write(f"    path = {escaped_path}\n".encode())
+
+            # Close and reopen to reload config
+            r.close()
+            r = Repo(repo_path)
+
+            # Check if include was processed
+            config = r.get_config()
+            self.assertEqual(b"true", config.get((b"core",), b"autocrlf"))
+            r.close()