소스 검색

Add comprehensive include/includeIf support to config parser

This implements full support for Git's include and includeIf directives:

Features:
- Basic include functionality with path resolution
- includeIf with gitdir condition evaluation (including pattern matching)
- Circular include detection and prevention
- Include depth limiting to prevent infinite recursion
- Proper handling of relative and absolute include paths
- Case-insensitive gitdir matching (gitdir/i)
- Graceful handling of missing include files

The implementation follows Git's behavior as documented in git-config(1)
and supports the most commonly used include scenarios.

Fixes #1216
Jelmer Vernooij 1 개월 전
부모
커밋
af1e1b46a8
5개의 변경된 파일945개의 추가작업 그리고 13개의 파일을 삭제
  1. 3 0
      NEWS
  2. 358 11
      dulwich/config.py
  3. 4 2
      dulwich/repo.py
  4. 440 0
      tests/test_config.py
  5. 140 0
      tests/test_repository.py

+ 3 - 0
NEWS

@@ -33,6 +33,9 @@
    in the remote were not removed locally due to incorrect path normalization
    in the remote were not removed locally due to incorrect path normalization
    on Windows. (#840, Jelmer Vernooij)
    on Windows. (#840, Jelmer Vernooij)
 
 
+ * Add support for includes in configuration files.
+   (#1216, Jelmer Vernooij)
+
 0.23.0	2025-06-21
 0.23.0	2025-06-21
 
 
  * Add basic ``rebase`` subcommand. (Jelmer Vernooij)
  * Add basic ``rebase`` subcommand. (Jelmer Vernooij)

+ 358 - 11
dulwich/config.py

@@ -25,13 +25,16 @@ Todo:
  * preserve formatting when updating configuration files
  * preserve formatting when updating configuration files
 """
 """
 
 
+import logging
 import os
 import os
 import sys
 import sys
 from collections.abc import Iterable, Iterator, KeysView, MutableMapping
 from collections.abc import Iterable, Iterator, KeysView, MutableMapping
 from contextlib import suppress
 from contextlib import suppress
+from pathlib import Path
 from typing import (
 from typing import (
     Any,
     Any,
     BinaryIO,
     BinaryIO,
+    Callable,
     Optional,
     Optional,
     Union,
     Union,
     overload,
     overload,
@@ -39,9 +42,72 @@ from typing import (
 
 
 from .file import GitFile
 from .file import GitFile
 
 
+logger = logging.getLogger(__name__)
+
+# Type for file opener callback
+FileOpener = Callable[[Union[str, os.PathLike]], BinaryIO]
+
+# 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
+
 SENTINEL = object()
 SENTINEL = object()
 
 
 
 
+def _match_gitdir_pattern(
+    path: bytes, pattern: bytes, ignorecase: bool = False
+) -> bool:
+    """Simple gitdir pattern matching for includeIf conditions.
+
+    This handles the basic gitdir patterns used in includeIf directives.
+    """
+    # Convert to strings for easier manipulation
+    path_str = path.decode("utf-8", errors="replace")
+    pattern_str = pattern.decode("utf-8", errors="replace")
+
+    # Normalize paths to use forward slashes for consistent matching
+    path_str = path_str.replace("\\", "/")
+    pattern_str = pattern_str.replace("\\", "/")
+
+    if ignorecase:
+        path_str = path_str.lower()
+        pattern_str = pattern_str.lower()
+
+    # Handle the common cases for gitdir patterns
+    if pattern_str.startswith("**/") and pattern_str.endswith("/**"):
+        # Pattern like **/dirname/** should match any path containing dirname
+        dirname = pattern_str[3:-3]  # Remove **/ and /**
+        # Check if path contains the directory name as a path component
+        return ("/" + dirname + "/") in path_str or path_str.endswith("/" + dirname)
+    elif pattern_str.startswith("**/"):
+        # Pattern like **/filename
+        suffix = pattern_str[3:]  # Remove **/
+        return suffix in path_str or path_str.endswith("/" + suffix)
+    elif pattern_str.endswith("/**"):
+        # Pattern like /path/to/dir/** should match /path/to/dir and any subdirectory
+        base_pattern = pattern_str[:-3]  # Remove /**
+        return path_str == base_pattern or path_str.startswith(base_pattern + "/")
+    elif "**" in pattern_str:
+        # Handle patterns with ** in the middle
+        parts = pattern_str.split("**")
+        if len(parts) == 2:
+            prefix, suffix = parts
+            # Path must start with prefix and end with suffix (if any)
+            if prefix and not path_str.startswith(prefix):
+                return False
+            if suffix and not path_str.endswith(suffix):
+                return False
+            return True
+
+    # Direct match or simple glob pattern
+    if "*" in pattern_str or "?" in pattern_str or "[" in pattern_str:
+        import fnmatch
+
+        return fnmatch.fnmatch(path_str, pattern_str)
+    else:
+        return path_str == pattern_str
+
+
 def lower_key(key):
 def lower_key(key):
     if isinstance(key, (bytes, str)):
     if isinstance(key, (bytes, str)):
         return key.lower()
         return key.lower()
@@ -50,7 +116,7 @@ def lower_key(key):
         # For config sections, only lowercase the section name (first element)
         # For config sections, only lowercase the section name (first element)
         # but preserve the case of subsection names (remaining elements)
         # but preserve the case of subsection names (remaining elements)
         if len(key) > 0:
         if len(key) > 0:
-            return (key[0].lower(),) + key[1:]
+            return (key[0].lower(), *key[1:])
         return key
         return key
 
 
     return key
     return key
@@ -559,10 +625,19 @@ def _parse_section_header_line(line: bytes) -> tuple[Section, bytes]:
     line = line[last + 1 :]
     line = line[last + 1 :]
     section: Section
     section: Section
     if len(pts) == 2:
     if len(pts) == 2:
-        if pts[1][:1] != b'"' or pts[1][-1:] != b'"':
-            raise ValueError(f"Invalid subsection {pts[1]!r}")
-        else:
+        # Handle subsections - Git allows more complex syntax for certain sections like includeIf
+        if pts[1][:1] == b'"' and pts[1][-1:] == b'"':
+            # Standard quoted subsection
             pts[1] = pts[1][1:-1]
             pts[1] = pts[1][1:-1]
+        elif pts[0] == b"includeIf":
+            # Special handling for includeIf sections which can have complex conditions
+            # Git allows these without strict quote validation
+            pts[1] = pts[1].strip()
+            if pts[1][:1] == b'"' and pts[1][-1:] == b'"':
+                pts[1] = pts[1][1:-1]
+        else:
+            # Other sections must have quoted subsections
+            raise ValueError(f"Invalid subsection {pts[1]!r}")
         if not _check_section_name(pts[0]):
         if not _check_section_name(pts[0]):
             raise ValueError(f"invalid section name {pts[0]!r}")
             raise ValueError(f"invalid section name {pts[0]!r}")
         section = (pts[0], pts[1])
         section = (pts[0], pts[1])
@@ -589,11 +664,39 @@ class ConfigFile(ConfigDict):
     ) -> None:
     ) -> None:
         super().__init__(values=values, encoding=encoding)
         super().__init__(values=values, encoding=encoding)
         self.path: Optional[str] = None
         self.path: Optional[str] = None
+        self._included_paths: set[str] = set()  # Track included files to prevent cycles
 
 
     @classmethod
     @classmethod
-    def from_file(cls, f: BinaryIO) -> "ConfigFile":
-        """Read configuration from a file-like object."""
+    def from_file(
+        cls,
+        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,
+    ) -> "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
+        """
+        if include_depth > max_include_depth:
+            # Prevent excessive recursion
+            raise ValueError(f"Maximum include depth ({max_include_depth}) exceeded")
+
         ret = cls()
         ret = cls()
+        if included_paths is not None:
+            ret._included_paths = included_paths.copy()
+
         section: Optional[Section] = None
         section: Optional[Section] = None
         setting = None
         setting = None
         continuation = None
         continuation = None
@@ -626,6 +729,19 @@ class ConfigFile(ConfigDict):
                     continuation = None
                     continuation = None
                     value = _parse_string(value)
                     value = _parse_string(value)
                     ret._values[section][setting] = value
                     ret._values[section][setting] = value
+
+                    # Process include/includeIf directives
+                    ret._handle_include_directive(
+                        section,
+                        setting,
+                        value,
+                        config_dir=config_dir,
+                        repo_dir=repo_dir,
+                        include_depth=include_depth,
+                        max_include_depth=max_include_depth,
+                        file_opener=file_opener,
+                    )
+
                     setting = None
                     setting = None
             else:  # continuation line
             else:  # continuation line
                 assert continuation is not None
                 assert continuation is not None
@@ -638,16 +754,247 @@ class ConfigFile(ConfigDict):
                     continuation += line
                     continuation += line
                     value = _parse_string(continuation)
                     value = _parse_string(continuation)
                     ret._values[section][setting] = value
                     ret._values[section][setting] = value
+
+                    # Process include/includeIf directives
+                    ret._handle_include_directive(
+                        section,
+                        setting,
+                        value,
+                        config_dir=config_dir,
+                        repo_dir=repo_dir,
+                        include_depth=include_depth,
+                        max_include_depth=max_include_depth,
+                        file_opener=file_opener,
+                    )
+
                     continuation = None
                     continuation = None
                     setting = None
                     setting = None
         return ret
         return ret
 
 
+    def _handle_include_directive(
+        self,
+        section: Optional[Section],
+        setting: bytes,
+        value: bytes,
+        *,
+        config_dir: Optional[str],
+        repo_dir: Optional[str],
+        include_depth: int,
+        max_include_depth: int,
+        file_opener: Optional[FileOpener],
+    ) -> None:
+        """Handle include/includeIf directives during config parsing."""
+        if (
+            section is not None
+            and setting == b"path"
+            and (
+                section[0].lower() == b"include"
+                or (len(section) > 1 and section[0].lower() == b"includeif")
+            )
+        ):
+            self._process_include(
+                section,
+                value,
+                config_dir=config_dir,
+                repo_dir=repo_dir,
+                include_depth=include_depth,
+                max_include_depth=max_include_depth,
+                file_opener=file_opener,
+            )
+
+    def _process_include(
+        self,
+        section: Section,
+        path_value: bytes,
+        *,
+        config_dir: Optional[str],
+        repo_dir: Optional[str],
+        include_depth: int,
+        max_include_depth: int,
+        file_opener: Optional[FileOpener],
+    ) -> None:
+        """Process an include or includeIf directive."""
+        path_str = path_value.decode(self.encoding, errors="replace")
+
+        # 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):
+                return
+
+        # Resolve the include path
+        include_path = self._resolve_include_path(path_str, config_dir)
+        if not include_path:
+            return
+
+        # Check for circular includes
+        try:
+            abs_path = str(Path(include_path).resolve())
+        except (OSError, ValueError) as e:
+            # Invalid path - log and skip
+            logger.debug("Invalid include path %r: %s", include_path, e)
+            return
+        if abs_path in self._included_paths:
+            return
+
+        # Load and merge the included file
+        try:
+            # Use provided file opener or default to GitFile
+            if file_opener is None:
+
+                def opener(path):
+                    return GitFile(path, "rb")
+            else:
+                opener = file_opener
+
+            with opener(include_path) as included_file:
+                # Track this path to prevent cycles
+                self._included_paths.add(abs_path)
+
+                # Parse the included file
+                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,
+                )
+
+                # Merge the included configuration
+                self._merge_config(included_config)
+        except OSError as e:
+            # Git silently ignores missing or unreadable include files
+            # Log for debugging purposes
+            logger.debug("Failed to read include file %r: %s", include_path, e)
+
+    def _merge_config(self, other: "ConfigFile") -> None:
+        """Merge another config file into this one."""
+        for section, values in other._values.items():
+            if section not in self._values:
+                self._values[section] = CaseInsensitiveOrderedMultiDict()
+            for key, value in values.items():
+                self._values[section][key] = value
+
+    def _resolve_include_path(
+        self, path: str, config_dir: Optional[str]
+    ) -> Optional[str]:
+        """Resolve an include path to an absolute path."""
+        # Expand ~ to home directory
+        path = os.path.expanduser(path)
+
+        # If path is relative and we have a config directory, make it relative to that
+        if not os.path.isabs(path) and config_dir:
+            path = os.path.join(config_dir, path)
+
+        return path
+
+    def _evaluate_includeif_condition(
+        self, condition: str, repo_dir: Optional[str]
+    ) -> 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
+        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
+
+        # 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)
+            return False
+
+        # Expand ~ in pattern and normalize
+        pattern = os.path.expanduser(pattern)
+        pattern = self._normalize_gitdir_pattern(pattern)
+
+        # 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")
+
+        return _match_gitdir_pattern(
+            repo_path_bytes, pattern_bytes, ignorecase=not case_sensitive
+        )
+
+    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("\\", "/")
+
+        # 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
+
+        # If pattern ends with /, append **
+        if pattern.endswith("/"):
+            pattern = pattern + "**"
+
+        return pattern
+
     @classmethod
     @classmethod
-    def from_path(cls, path: Union[str, os.PathLike]) -> "ConfigFile":
-        """Read configuration from a file on disk."""
-        with GitFile(path, "rb") as f:
-            ret = cls.from_file(f)
-            ret.path = os.fspath(path)
+    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,
+    ) -> "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
+        """
+        abs_path = os.fspath(path)
+        config_dir = os.path.dirname(abs_path)
+
+        # Use provided file opener or default to GitFile
+        if file_opener is None:
+
+            def opener(p):
+                return GitFile(p, "rb")
+        else:
+            opener = file_opener
+
+        with opener(abs_path) as f:
+            ret = cls.from_file(
+                f,
+                config_dir=config_dir,
+                repo_dir=repo_dir,
+                max_include_depth=max_include_depth,
+                file_opener=file_opener,
+            )
+            ret.path = abs_path
             return ret
             return ret
 
 
     def write_to_path(self, path: Optional[Union[str, os.PathLike]] = None) -> None:
     def write_to_path(self, path: Optional[Union[str, os.PathLike]] = None) -> None:

+ 4 - 2
dulwich/repo.py

@@ -1727,7 +1727,8 @@ class Repo(BaseRepo):
 
 
         path = os.path.join(self.commondir(), "config.worktree")
         path = os.path.join(self.commondir(), "config.worktree")
         try:
         try:
-            return ConfigFile.from_path(path)
+            # Pass the git directory (not working tree) for includeIf gitdir conditions
+            return ConfigFile.from_path(path, repo_dir=self._controldir)
         except FileNotFoundError:
         except FileNotFoundError:
             cf = ConfigFile()
             cf = ConfigFile()
             cf.path = path
             cf.path = path
@@ -1742,7 +1743,8 @@ class Repo(BaseRepo):
 
 
         path = os.path.join(self._commondir, "config")
         path = os.path.join(self._commondir, "config")
         try:
         try:
-            return ConfigFile.from_path(path)
+            # Pass the git directory (not working tree) for includeIf gitdir conditions
+            return ConfigFile.from_path(path, repo_dir=self._controldir)
         except FileNotFoundError:
         except FileNotFoundError:
             ret = ConfigFile()
             ret = ConfigFile()
             ret.path = path
             ret.path = path

+ 440 - 0
tests/test_config.py

@@ -23,6 +23,7 @@
 
 
 import os
 import os
 import sys
 import sys
+import tempfile
 from io import BytesIO
 from io import BytesIO
 from unittest import skipIf
 from unittest import skipIf
 from unittest.mock import patch
 from unittest.mock import patch
@@ -167,6 +168,21 @@ class ConfigFileTests(TestCase):
         cf = self.from_file(b"[branch.foo]\nfoo = bar\n")
         cf = self.from_file(b"[branch.foo]\nfoo = bar\n")
         self.assertEqual(b"bar", cf.get((b"branch", b"foo"), b"foo"))
         self.assertEqual(b"bar", cf.get((b"branch", b"foo"), b"foo"))
 
 
+    def test_from_file_includeif_hasconfig(self) -> None:
+        """Test parsing includeIf sections with hasconfig conditions."""
+        # Test case from issue #1216
+        cf = self.from_file(
+            b'[includeIf "hasconfig:remote.*.url:ssh://org-*@github.com/**"]\n'
+            b"    path = ~/.config/git/.work\n"
+        )
+        self.assertEqual(
+            b"~/.config/git/.work",
+            cf.get(
+                (b"includeIf", b"hasconfig:remote.*.url:ssh://org-*@github.com/**"),
+                b"path",
+            ),
+        )
+
     def test_write_preserve_multivar(self) -> None:
     def test_write_preserve_multivar(self) -> None:
         cf = self.from_file(b"[core]\nfoo = bar\nfoo = blah\n")
         cf = self.from_file(b"[core]\nfoo = bar\nfoo = blah\n")
         f = BytesIO()
         f = BytesIO()
@@ -357,6 +373,430 @@ who\"
             # Clean up
             # Clean up
             os.unlink(temp_path)
             os.unlink(temp_path)
 
 
+    def test_include_basic(self) -> None:
+        """Test basic include functionality."""
+        with tempfile.TemporaryDirectory() as tmpdir:
+            # Create included config file
+            included_path = os.path.join(tmpdir, "included.config")
+            with open(included_path, "wb") as f:
+                f.write(
+                    b"[user]\n    name = Included User\n    email = included@example.com\n"
+                )
+
+            # Create main config with include
+            main_config = self.from_file(
+                b"[user]\n    name = Main User\n[include]\n    path = included.config\n"
+            )
+
+            # Should not include anything without proper directory context
+            self.assertEqual(b"Main User", main_config.get((b"user",), b"name"))
+            with self.assertRaises(KeyError):
+                main_config.get((b"user",), b"email")
+
+            # Now test with proper file loading
+            main_path = os.path.join(tmpdir, "main.config")
+            with open(main_path, "wb") as f:
+                f.write(
+                    b"[user]\n    name = Main User\n[include]\n    path = included.config\n"
+                )
+
+            # Load from path to get include functionality
+            cf = ConfigFile.from_path(main_path)
+            self.assertEqual(b"Included User", cf.get((b"user",), b"name"))
+            self.assertEqual(b"included@example.com", cf.get((b"user",), b"email"))
+
+    def test_include_absolute_path(self) -> None:
+        """Test include with absolute path."""
+        with tempfile.TemporaryDirectory() as tmpdir:
+            # Use realpath to resolve any symlinks (important on macOS and Windows)
+            tmpdir = os.path.realpath(tmpdir)
+
+            # Create included config file
+            included_path = os.path.join(tmpdir, "included.config")
+            with open(included_path, "wb") as f:
+                f.write(b"[core]\n    bare = true\n")
+
+            # Create main config with absolute include path
+            main_path = os.path.join(tmpdir, "main.config")
+            with open(main_path, "wb") as f:
+                # Properly escape backslashes in Windows paths
+                escaped_path = included_path.replace("\\", "\\\\")
+                f.write(f"[include]\n    path = {escaped_path}\n".encode())
+
+            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."""
+        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")
+
+            # Create main config with includeIf
+            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()
+                )
+
+            # 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"))
+
+    def test_includeif_gitdir_no_match(self) -> None:
+        """Test includeIf with gitdir condition that doesn't match."""
+        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")
+            with open(included_path, "wb") as f:
+                f.write(b"[user]\n    email = work@example.com\n")
+
+            # Create main config with includeIf
+            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()
+                )
+
+            # 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")
+
+    def test_includeif_gitdir_pattern(self) -> None:
+        """Test includeIf with gitdir pattern matching."""
+        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")
+            with open(included_path, "wb") as f:
+                f.write(b"[user]\n    email = work@company.com\n")
+
+            # Create main config with pattern
+            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')
+
+            # 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"))
+
+    def test_include_circular(self) -> None:
+        """Test that circular includes are handled properly."""
+        with tempfile.TemporaryDirectory() as tmpdir:
+            # Create two configs that include each other
+            config1_path = os.path.join(tmpdir, "config1")
+            config2_path = os.path.join(tmpdir, "config2")
+
+            with open(config1_path, "wb") as f:
+                f.write(b"[user]\n    name = User1\n[include]\n    path = config2\n")
+
+            with open(config2_path, "wb") as f:
+                f.write(
+                    b"[user]\n    email = user2@example.com\n[include]\n    path = config1\n"
+                )
+
+            # Should handle circular includes gracefully
+            cf = ConfigFile.from_path(config1_path)
+            self.assertEqual(b"User1", cf.get((b"user",), b"name"))
+            self.assertEqual(b"user2@example.com", cf.get((b"user",), b"email"))
+
+    def test_include_missing_file(self) -> None:
+        """Test that missing include files are ignored."""
+        with tempfile.TemporaryDirectory() as tmpdir:
+            # Create config with include of non-existent file
+            config_path = os.path.join(tmpdir, "config")
+            with open(config_path, "wb") as f:
+                f.write(
+                    b"[user]\n    name = TestUser\n[include]\n    path = missing.config\n"
+                )
+
+            # Should not fail, just ignore missing include
+            cf = ConfigFile.from_path(config_path)
+            self.assertEqual(b"TestUser", cf.get((b"user",), b"name"))
+
+    def test_include_depth_limit(self) -> None:
+        """Test that excessive include depth is prevented."""
+        with tempfile.TemporaryDirectory() as tmpdir:
+            # Create a chain of includes that exceeds depth limit
+            for i in range(15):
+                config_path = os.path.join(tmpdir, f"config{i}")
+                with open(config_path, "wb") as f:
+                    if i == 0:
+                        f.write(b"[user]\n    name = User0\n")
+                    f.write(f"[include]\n    path = config{i + 1}\n".encode())
+
+            # Should raise error due to depth limit
+            with self.assertRaises(ValueError) as cm:
+                ConfigFile.from_path(os.path.join(tmpdir, "config0"))
+            self.assertIn("include depth", str(cm.exception))
+
+    def test_include_with_custom_file_opener(self) -> None:
+        """Test include functionality with a custom file opener for security."""
+        with tempfile.TemporaryDirectory() as tmpdir:
+            # Create config files
+            included_path = os.path.join(tmpdir, "included.config")
+            with open(included_path, "wb") as f:
+                f.write(b"[user]\n    email = custom@example.com\n")
+
+            restricted_path = os.path.join(tmpdir, "restricted.config")
+            with open(restricted_path, "wb") as f:
+                f.write(b"[user]\n    email = restricted@example.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"[include]\n    path = included.config\n")
+                f.write(b"[include]\n    path = restricted.config\n")
+
+            # Define a custom file opener that restricts access
+            allowed_files = {included_path, main_path}
+
+            def secure_file_opener(path):
+                path_str = os.fspath(path)
+                if path_str not in allowed_files:
+                    raise PermissionError(f"Access denied to {path}")
+                return open(path_str, "rb")
+
+            # Load config with restricted file access
+            cf = ConfigFile.from_path(main_path, file_opener=secure_file_opener)
+
+            # Should have the main config and included config, but not restricted
+            self.assertEqual(b"Test User", cf.get((b"user",), b"name"))
+            self.assertEqual(b"custom@example.com", cf.get((b"user",), b"email"))
+            # Email from restricted.config should not be loaded
+
+    def test_unknown_includeif_condition(self) -> None:
+        """Test that unknown includeIf conditions are silently ignored (like Git)."""
+        with tempfile.TemporaryDirectory() as tmpdir:
+            # Create included config file
+            included_path = os.path.join(tmpdir, "included.config")
+            with open(included_path, "wb") as f:
+                f.write(b"[user]\n    email = included@example.com\n")
+
+            # Create main config with unknown includeIf condition
+            main_path = os.path.join(tmpdir, "main.config")
+            with open(main_path, "wb") as f:
+                f.write(b"[user]\n    name = Main User\n")
+                f.write(
+                    b'[includeIf "unknowncondition:foo"]\n    path = included.config\n'
+                )
+
+            # Should not fail, just ignore the unknown condition
+            cf = ConfigFile.from_path(main_path)
+            self.assertEqual(b"Main User", cf.get((b"user",), b"name"))
+            # Email should not be included because condition is unknown
+            with self.assertRaises(KeyError):
+                cf.get((b"user",), b"email")
+
+    def test_missing_include_file_logging(self) -> None:
+        """Test that missing include files are logged but don't cause failure."""
+        import logging
+        from io import StringIO
+
+        # Set up logging capture
+        log_capture = StringIO()
+        handler = logging.StreamHandler(log_capture)
+        handler.setLevel(logging.DEBUG)
+        logger = logging.getLogger("dulwich.config")
+        logger.addHandler(handler)
+        logger.setLevel(logging.DEBUG)
+
+        try:
+            with tempfile.TemporaryDirectory() as tmpdir:
+                config_path = os.path.join(tmpdir, "test.config")
+                with open(config_path, "wb") as f:
+                    f.write(b"[user]\n    name = Test User\n")
+                    f.write(b"[include]\n    path = nonexistent.config\n")
+
+                # Should not fail, just log
+                cf = ConfigFile.from_path(config_path)
+                self.assertEqual(b"Test User", cf.get((b"user",), b"name"))
+
+                # Check that it was logged
+                log_output = log_capture.getvalue()
+                self.assertIn("Failed to read include file", log_output)
+                self.assertIn("nonexistent.config", log_output)
+        finally:
+            logger.removeHandler(handler)
+
+    def test_invalid_include_path_logging(self) -> None:
+        """Test that invalid include paths are logged but don't cause failure."""
+        import logging
+        from io import StringIO
+
+        # Set up logging capture
+        log_capture = StringIO()
+        handler = logging.StreamHandler(log_capture)
+        handler.setLevel(logging.DEBUG)
+        logger = logging.getLogger("dulwich.config")
+        logger.addHandler(handler)
+        logger.setLevel(logging.DEBUG)
+
+        try:
+            with tempfile.TemporaryDirectory() as tmpdir:
+                config_path = os.path.join(tmpdir, "test.config")
+                with open(config_path, "wb") as f:
+                    f.write(b"[user]\n    name = Test User\n")
+                    # Use null bytes which are invalid in paths
+                    f.write(b"[include]\n    path = /invalid\x00path/file.config\n")
+
+                # Should not fail, just log
+                cf = ConfigFile.from_path(config_path)
+                self.assertEqual(b"Test User", cf.get((b"user",), b"name"))
+
+                # Check that it was logged
+                log_output = log_capture.getvalue()
+                self.assertIn("Invalid include path", log_output)
+        finally:
+            logger.removeHandler(handler)
+
+    def test_unknown_includeif_condition_logging(self) -> None:
+        """Test that unknown includeIf conditions are logged."""
+        import logging
+        from io import StringIO
+
+        # Set up logging capture
+        log_capture = StringIO()
+        handler = logging.StreamHandler(log_capture)
+        handler.setLevel(logging.DEBUG)
+        logger = logging.getLogger("dulwich.config")
+        logger.addHandler(handler)
+        logger.setLevel(logging.DEBUG)
+
+        try:
+            with tempfile.TemporaryDirectory() as tmpdir:
+                config_path = os.path.join(tmpdir, "test.config")
+                with open(config_path, "wb") as f:
+                    f.write(b"[user]\n    name = Test User\n")
+                    f.write(
+                        b'[includeIf "futurefeature:value"]\n    path = other.config\n'
+                    )
+
+                # Should not fail, just log
+                cf = ConfigFile.from_path(config_path)
+                self.assertEqual(b"Test User", cf.get((b"user",), b"name"))
+
+                # Check that it was logged
+                log_output = log_capture.getvalue()
+                self.assertIn("Unknown includeIf condition", log_output)
+                self.assertIn("futurefeature:value", log_output)
+        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:
+            # Use realpath to resolve any symlinks
+            tmpdir = os.path.realpath(tmpdir)
+
+            # Create a chain of includes
+            final_config = os.path.join(tmpdir, "final.config")
+            with open(final_config, "wb") as f:
+                f.write(b"[feature]\n    enabled = true\n")
+
+            middle_config = os.path.join(tmpdir, "middle.config")
+            with open(middle_config, "wb") as f:
+                f.write(b"[user]\n    email = test@example.com\n")
+                escaped_final_config = final_config.replace("\\", "\\\\")
+                f.write(f"[include]\n    path = {escaped_final_config}\n".encode())
+
+            main_config = os.path.join(tmpdir, "main.config")
+            with open(main_config, "wb") as f:
+                f.write(b"[user]\n    name = Test User\n")
+                escaped_middle_config = middle_config.replace("\\", "\\\\")
+                f.write(f"[include]\n    path = {escaped_middle_config}\n".encode())
+
+            # Track file access order
+            access_order = []
+
+            def ordering_file_opener(path):
+                path_str = os.fspath(path)
+                access_order.append(os.path.basename(path_str))
+                return open(path_str, "rb")
+
+            # Load config
+            cf = ConfigFile.from_path(main_config, file_opener=ordering_file_opener)
+
+            # Verify all values were loaded
+            self.assertEqual(b"Test User", cf.get((b"user",), b"name"))
+            self.assertEqual(b"test@example.com", cf.get((b"user",), b"email"))
+            self.assertEqual(b"true", cf.get((b"feature",), b"enabled"))
+
+            # Verify access order
+            self.assertEqual(
+                ["main.config", "middle.config", "final.config"], access_order
+            )
+
 
 
 class ConfigDictTests(TestCase):
 class ConfigDictTests(TestCase):
     def test_get_set(self) -> None:
     def test_get_set(self) -> None:

+ 140 - 0
tests/test_repository.py

@@ -1697,3 +1697,143 @@ class CheckUserIdentityTests(TestCase):
         self.assertRaises(
         self.assertRaises(
             InvalidUserIdentity, check_user_identity, b"Contains\nnewline byte <>"
             InvalidUserIdentity, check_user_identity, b"Contains\nnewline byte <>"
         )
         )
+
+
+class RepoConfigIncludeIfTests(TestCase):
+    """Test includeIf functionality in repository config loading."""
+
+    def test_repo_config_includeif_gitdir(self) -> None:
+        """Test that includeIf gitdir conditions work when loading repo 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)
+            # Use realpath to resolve any symlinks (important on macOS)
+            repo_path = os.path.realpath(repo_path)
+
+            # 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    email = work@example.com\n")
+
+            # Add includeIf to the repo config
+            config_path = os.path.join(repo_path, ".git", "config")
+            with open(config_path, "ab") as f:
+                f.write(f'\n[includeIf "gitdir:{repo_path}/.git/"]\n'.encode())
+                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"work@example.com", config.get((b"user",), b"email"))
+            r.close()
+
+    def test_repo_config_includeif_gitdir_pattern(self) -> None:
+        """Test includeIf gitdir pattern matching in repository config."""
+        import tempfile
+
+        from dulwich.repo import Repo
+
+        with tempfile.TemporaryDirectory() as tmpdir:
+            # Create a repository under "work" directory
+            work_dir = os.path.join(tmpdir, "work", "project1")
+            os.makedirs(os.path.dirname(work_dir), exist_ok=True)
+            r = Repo.init(work_dir, 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    email = work@company.com\n")
+
+            # Add includeIf with pattern to the repo config
+            config_path = os.path.join(work_dir, ".git", "config")
+            with open(config_path, "ab") as f:
+                # Use a pattern that will match paths containing /work/
+                f.write(b'\n[includeIf "gitdir:**/work/**"]\n')
+                escaped_path = included_path.replace("\\", "\\\\")
+                f.write(f"    path = {escaped_path}\n".encode())
+
+            # Close and reopen to reload config
+            r.close()
+            r = Repo(work_dir)
+
+            # Check if include was processed
+            config = r.get_config()
+            self.assertEqual(b"work@company.com", config.get((b"user",), b"email"))
+            r.close()
+
+    def test_repo_config_includeif_no_match(self) -> None:
+        """Test that includeIf doesn't include when condition doesn't match."""
+        import tempfile
+
+        from dulwich.repo import Repo
+
+        with tempfile.TemporaryDirectory() as tmpdir:
+            # Create a repository
+            repo_path = os.path.join(tmpdir, "personal", "project")
+            os.makedirs(os.path.dirname(repo_path), exist_ok=True)
+            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    email = work@company.com\n")
+
+            # Add includeIf that won't match
+            config_path = os.path.join(repo_path, ".git", "config")
+            with open(config_path, "ab") as f:
+                f.write(b'\n[includeIf "gitdir:**/work/**"]\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 that include was NOT processed
+            config = r.get_config()
+            with self.assertRaises(KeyError):
+                config.get((b"user",), b"email")
+            r.close()
+
+    def test_bare_repo_config_includeif(self) -> None:
+        """Test includeIf in bare repository."""
+        import tempfile
+
+        from dulwich.repo import Repo
+
+        with tempfile.TemporaryDirectory() as tmpdir:
+            # Create a bare repository
+            repo_path = os.path.join(tmpdir, "bare.git")
+            r = Repo.init_bare(repo_path, mkdir=True)
+            # Use realpath to resolve any symlinks (important on macOS)
+            repo_path = os.path.realpath(repo_path)
+
+            # Create an included config file
+            included_path = os.path.join(tmpdir, "server.config")
+            with open(included_path, "wb") as f:
+                f.write(b"[receive]\n    denyNonFastForwards = true\n")
+
+            # Add includeIf to the repo config
+            config_path = os.path.join(repo_path, "config")
+            with open(config_path, "ab") as f:
+                f.write(f'\n[includeIf "gitdir:{repo_path}/"]\n'.encode())
+                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"receive",), b"denyNonFastForwards"))
+            r.close()