瀏覽代碼

Implement comprehensive Git config includes support

Add support for hasconfig:, onbranch:, and relative gitdir patterns in includeIf conditions:

- hasconfig: condition matches configuration values against glob patterns
- onbranch: condition matches current branch name against patterns
- Relative gitdir patterns (starting with ./) resolve relative to config file location
- All conditions support glob patterns with * and ** wildcards
- Comprehensive test coverage for all new functionality

This completes the Git config includes feature implementation, providing
compatibility with Git's conditional include system.
Jelmer Vernooij 1 月之前
父節點
當前提交
335396feaf
共有 2 個文件被更改,包括 333 次插入10 次删除
  1. 165 10
      dulwich/config.py
  2. 168 0
      tests/test_config.py

+ 165 - 10
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
@@ -819,7 +820,7 @@ 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, repo_dir, config_dir):
                 return
 
         # Resolve the include path
@@ -893,34 +894,188 @@ class ConfigFile(ConfigDict):
         return path
 
     def _evaluate_includeif_condition(
-        self, condition: str, repo_dir: Optional[str]
+        self, condition: str, repo_dir: Optional[str], config_dir: Optional[str] = None
     ) -> bool:
         """Evaluate an includeIf condition."""
         if condition.startswith("gitdir:"):
-            return self._evaluate_gitdir_condition(condition[7:], repo_dir)
+            return self._evaluate_gitdir_condition(condition[7:], repo_dir, config_dir)
         elif condition.startswith("gitdir/i:"):
             return self._evaluate_gitdir_condition(
-                condition[9:], repo_dir, case_sensitive=False
+                condition[9:], repo_dir, config_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
+            return self._evaluate_hasconfig_condition(condition[10:])
+        elif condition.startswith("onbranch:"):
+            return self._evaluate_onbranch_condition(condition[9:], repo_dir)
         else:
             # Unknown condition type - log and ignore (Git behavior)
             logger.debug("Unknown includeIf condition: %r", condition)
             return False
 
+    def _evaluate_hasconfig_condition(self, condition: str) -> bool:
+        """Evaluate a hasconfig condition.
+
+        Format: hasconfig:config.key:pattern
+        Example: hasconfig:remote.*.url:ssh://org-*@github.com/**
+        """
+        try:
+            # 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
+
+            config_key, pattern = parts
+
+            # 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
+
+            # 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)
+
+                try:
+                    values = list(self.get_multivar(section, name))
+                    for value in values:
+                        if self._match_hasconfig_pattern(value, pattern):
+                            return True
+                except KeyError:
+                    pass
+
+            return False
+        except Exception as e:
+            logger.debug("Error evaluating hasconfig condition %r: %s", condition, e)
+            return False
+
+    def _match_hasconfig_pattern(self, value: bytes, pattern: str) -> bool:
+        """Match a config value against a hasconfig pattern.
+
+        Supports simple glob patterns like * and **.
+        """
+        value_str = value.decode(self.encoding, errors="replace")
+
+        # Convert glob pattern to regex
+        # Escape special regex chars except * and **
+        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_str))
+        except re.error:
+            logger.debug("Invalid hasconfig pattern: %r", pattern)
+            return False
+
+    def _evaluate_onbranch_condition(
+        self, pattern: str, repo_dir: Optional[str]
+    ) -> bool:
+        """Evaluate an onbranch condition.
+
+        Format: onbranch:pattern
+        Example: onbranch:main, onbranch:feature/*, onbranch:**/hotfix-*
+        """
+        if not repo_dir:
+            return False
+
+        try:
+            # Try to read HEAD to get current branch
+            head_path = os.path.join(repo_dir, ".git", "HEAD")
+            if os.path.isfile(head_path):
+                with open(head_path, "rb") as f:
+                    head_content = f.read().strip()
+
+                # Check if HEAD points to a branch
+                if head_content.startswith(b"ref: refs/heads/"):
+                    branch_name = head_content[16:].decode("utf-8", errors="replace")
+                    return self._match_branch_pattern(branch_name, pattern)
+
+            # If repo_dir itself is .git directory
+            elif repo_dir.endswith(".git"):
+                head_path = os.path.join(repo_dir, "HEAD")
+                if os.path.isfile(head_path):
+                    with open(head_path, "rb") as f:
+                        head_content = f.read().strip()
+
+                    if head_content.startswith(b"ref: refs/heads/"):
+                        branch_name = head_content[16:].decode(
+                            "utf-8", errors="replace"
+                        )
+                        return self._match_branch_pattern(branch_name, pattern)
+
+            return False
+        except Exception as e:
+            logger.debug("Error evaluating onbranch condition %r: %s", pattern, e)
+            return False
+
+    def _match_branch_pattern(self, branch_name: str, pattern: str) -> bool:
+        """Match a branch name against an onbranch pattern.
+
+        Supports glob patterns like *, **, and exact matches.
+        """
+        # Convert glob pattern to regex
+        pattern_escaped = re.escape(pattern)
+        # Replace escaped \*\* with .* (match anything including /)
+        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, branch_name))
+        except re.error:
+            logger.debug("Invalid onbranch pattern: %r", pattern)
+            return False
+
     def _evaluate_gitdir_condition(
-        self, pattern: str, repo_dir: Optional[str], case_sensitive: bool = True
+        self,
+        pattern: str,
+        repo_dir: Optional[str],
+        config_dir: Optional[str] = None,
+        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)
+        # Handle relative patterns (starting with ./)
         if pattern.startswith("./"):
-            return False
+            if not config_dir:
+                # Can't resolve relative pattern without config directory
+                return False
+            # Make pattern relative to config directory and normalize
+            pattern = os.path.normpath(os.path.join(config_dir, pattern[2:]))
 
         # Normalize repository path
         try:

+ 168 - 0
tests/test_config.py

@@ -501,6 +501,174 @@ who\"
             cf = ConfigFile.from_path(main_path, repo_dir=work_dir)
             self.assertEqual(b"work@company.com", cf.get((b"user",), b"email"))
 
+    def test_includeif_hasconfig(self) -> None:
+        """Test includeIf with hasconfig conditions."""
+        with tempfile.TemporaryDirectory() as tmpdir:
+            # Create included config file
+            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")
+
+            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(
+                    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 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_hasconfig_wildcard(self) -> None:
+        """Test includeIf hasconfig with wildcard patterns."""
+        with tempfile.TemporaryDirectory() as tmpdir:
+            # Create included config
+            included_path = os.path.join(tmpdir, "included.config")
+            with open(included_path, "wb") as f:
+                f.write(b"[user]\n    name = IncludedUser\n")
+
+            # 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(
+                    b"[core]\n"
+                    b"    autocrlf = true\n"
+                    b'[includeIf "hasconfig:core.autocrlf:true"]\n'
+                    b"    path = included.config\n"
+                )
+
+            # 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_hasconfig_no_match(self) -> None:
+        """Test includeIf hasconfig when condition doesn't match."""
+        with tempfile.TemporaryDirectory() as tmpdir:
+            # Create included config
+            included_path = os.path.join(tmpdir, "included.config")
+            with open(included_path, "wb") as f:
+                f.write(b"[user]\n    name = IncludedUser\n")
+
+            # Create main config with non-matching hasconfig condition
+            main_path = os.path.join(tmpdir, "main.config")
+            with open(main_path, "wb") as f:
+                f.write(
+                    b"[core]\n"
+                    b"    autocrlf = false\n"
+                    b'[includeIf "hasconfig:core.autocrlf:true"]\n'
+                    b"    path = included.config\n"
+                )
+
+            # 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_includeif_gitdir_relative(self) -> None:
+        """Test includeIf with relative gitdir patterns."""
+        with tempfile.TemporaryDirectory() as tmpdir:
+            # Create a directory structure
+            config_dir = os.path.join(tmpdir, "config")
+            repo_dir = os.path.join(tmpdir, "repo")
+            os.makedirs(config_dir)
+            os.makedirs(repo_dir)
+
+            # Create included config
+            included_path = os.path.join(config_dir, "work.config")
+            with open(included_path, "wb") as f:
+                f.write(b"[user]\n    email = relative@example.com\n")
+
+            # Create main config with relative gitdir pattern
+            main_path = os.path.join(config_dir, "main.config")
+            with open(main_path, "wb") as f:
+                # Pattern ./../repo/** should match when config is in config/ and repo is in repo/
+                f.write(b'[includeIf "gitdir:./../repo/**"]\n    path = work.config\n')
+
+            # Load config with repo_dir that matches the relative pattern
+            cf = ConfigFile.from_path(main_path, repo_dir=repo_dir)
+            self.assertEqual(b"relative@example.com", cf.get((b"user",), b"email"))
+
+    def test_includeif_onbranch(self) -> None:
+        """Test includeIf with onbranch conditions."""
+        with tempfile.TemporaryDirectory() as tmpdir:
+            # Create a mock git repository
+            repo_dir = os.path.join(tmpdir, "repo")
+            git_dir = os.path.join(repo_dir, ".git")
+            os.makedirs(git_dir)
+
+            # Create HEAD file pointing to main branch
+            head_path = os.path.join(git_dir, "HEAD")
+            with open(head_path, "wb") as f:
+                f.write(b"ref: refs/heads/main\n")
+
+            # Create included configs for different branches
+            main_config_path = os.path.join(tmpdir, "main.config")
+            with open(main_config_path, "wb") as f:
+                f.write(b"[user]\n    email = main@example.com\n")
+
+            feature_config_path = os.path.join(tmpdir, "feature.config")
+            with open(feature_config_path, "wb") as f:
+                f.write(b"[user]\n    email = feature@example.com\n")
+
+            # Create main config with onbranch conditions
+            config_path = os.path.join(tmpdir, "config")
+            with open(config_path, "wb") as f:
+                f.write(
+                    b'[includeIf "onbranch:main"]\n'
+                    b"    path = main.config\n"
+                    b'[includeIf "onbranch:feature/*"]\n'
+                    b"    path = feature.config\n"
+                )
+
+            # Load config - should match main branch
+            cf = ConfigFile.from_path(config_path, repo_dir=repo_dir)
+            self.assertEqual(b"main@example.com", cf.get((b"user",), b"email"))
+
+            # Change branch to feature/test
+            with open(head_path, "wb") as f:
+                f.write(b"ref: refs/heads/feature/test\n")
+
+            # Reload config - should match feature branch pattern
+            cf = ConfigFile.from_path(config_path, repo_dir=repo_dir)
+            self.assertEqual(b"feature@example.com", cf.get((b"user",), b"email"))
+
+    def test_includeif_onbranch_gitdir(self) -> None:
+        """Test includeIf onbranch when repo_dir points to .git directory."""
+        with tempfile.TemporaryDirectory() as tmpdir:
+            # Create a mock git repository
+            git_dir = os.path.join(tmpdir, ".git")
+            os.makedirs(git_dir)
+
+            # Create HEAD file
+            head_path = os.path.join(git_dir, "HEAD")
+            with open(head_path, "wb") as f:
+                f.write(b"ref: refs/heads/develop\n")
+
+            # Create included config
+            included_path = os.path.join(tmpdir, "develop.config")
+            with open(included_path, "wb") as f:
+                f.write(b"[core]\n    autocrlf = false\n")
+
+            # Create main config
+            config_path = os.path.join(tmpdir, "config")
+            with open(config_path, "wb") as f:
+                f.write(b'[includeIf "onbranch:develop"]\n    path = develop.config\n')
+
+            # Load config with repo_dir pointing to .git
+            cf = ConfigFile.from_path(config_path, repo_dir=git_dir)
+            self.assertEqual(b"false", cf.get((b"core",), b"autocrlf"))
+
     def test_include_circular(self) -> None:
         """Test that circular includes are handled properly."""
         with tempfile.TemporaryDirectory() as tmpdir: