Explorar el Código

Merge branch 'master' into checkout

Jelmer Vernooij hace 2 meses
padre
commit
4b931c18fb
Se han modificado 9 ficheros con 217 adiciones y 143 borrados
  1. 1 1
      .github/workflows/pythontest.yml
  2. 8 0
      NEWS
  3. 3 3
      dulwich/client.py
  4. 5 3
      dulwich/config.py
  5. 151 133
      dulwich/merge.py
  6. 4 1
      dulwich/porcelain.py
  7. 1 0
      tests/__init__.py
  8. 40 2
      tests/test_config.py
  9. 4 0
      tests/test_merge.py

+ 1 - 1
.github/workflows/pythontest.yml

@@ -41,7 +41,7 @@ jobs:
       - name: Install dependencies
         run: |
           python -m pip install --upgrade pip
-          pip install --upgrade ".[fastimport,paramiko,https]"  setuptools-rust
+          pip install --upgrade ".[merge,fastimport,paramiko,https]"  setuptools-rust
       - name: Install gpg on supported platforms
         run: pip install --upgrade ".[pgp]"
         if: "matrix.os != 'windows-latest' && matrix.python-version != 'pypy3'"

+ 8 - 0
NEWS

@@ -29,6 +29,14 @@
  * Support switching branch in a way that updates
    working tree. (Jelmer Vernooij, #576)
 
+ * Fix typing for ``dulwich.client`` methods that take repositories.
+   (Jelmer Vernooij, #1521)
+
+ * Fix handling of casing of subsection names in config.
+   (Jelmer Vernooij, #1183)
+
+ * Update working tree in pull. (Jelmer Vernooij, #452)
+
 0.22.8	2025-03-02
 
  * Allow passing in plain strings to ``dulwich.porcelain.tag_create``

+ 3 - 3
dulwich/client.py

@@ -129,7 +129,7 @@ from .refs import (
     read_info_refs,
     split_peeled_refs,
 )
-from .repo import Repo
+from .repo import BaseRepo, Repo
 
 # Default ref prefix, used if none is specified.
 # GitHub defaults to just sending HEAD if no ref-prefix is
@@ -922,7 +922,7 @@ class GitClient:
     def fetch(
         self,
         path: str,
-        target: Repo,
+        target: BaseRepo,
         determine_wants: Optional[
             Callable[[dict[bytes, bytes], Optional[int]], list[bytes]]
         ] = None,
@@ -1831,7 +1831,7 @@ class LocalGitClient(GitClient):
     def fetch(
         self,
         path: str,
-        target: Repo,
+        target: BaseRepo,
         determine_wants: Optional[
             Callable[[dict[bytes, bytes], Optional[int]], list[bytes]]
         ] = None,

+ 5 - 3
dulwich/config.py

@@ -23,8 +23,6 @@
 
 Todo:
  * preserve formatting when updating configuration files
- * treat subsection names as case-insensitive for [branch.foo] style
-   subsections
 """
 
 import os
@@ -49,7 +47,11 @@ def lower_key(key):
         return key.lower()
 
     if isinstance(key, Iterable):
-        return type(key)(map(lower_key, key))  # type: ignore
+        # For config sections, only lowercase the section name (first element)
+        # but preserve the case of subsection names (remaining elements)
+        if len(key) > 0:
+            return (key[0].lower(),) + key[1:]
+        return key
 
     return key
 

+ 151 - 133
dulwich/merge.py

@@ -5,7 +5,7 @@ from typing import Optional, cast
 try:
     import merge3
 except ImportError:
-    merge3 = None
+    merge3 = None  # type: ignore
 
 from dulwich.object_store import BaseObjectStore
 from dulwich.objects import S_ISGITLINK, Blob, Commit, Tree
@@ -19,116 +19,24 @@ class MergeConflict(Exception):
         super().__init__(f"Merge conflict in {path!r}: {message}")
 
 
-class Merger:
-    """Handles git merge operations."""
+def _can_merge_lines(
+    base_lines: list[bytes], a_lines: list[bytes], b_lines: list[bytes]
+) -> bool:
+    """Check if lines can be merged without conflict."""
+    # If one side is unchanged, we can take the other side
+    if base_lines == a_lines:
+        return True
+    elif base_lines == b_lines:
+        return True
+    else:
+        # For now, treat any difference as a conflict
+        # A more sophisticated algorithm would check for non-overlapping changes
+        return False
 
-    def __init__(self, object_store: BaseObjectStore):
-        """Initialize merger.
 
-        Args:
-            object_store: Object store to read objects from
-        """
-        self.object_store = object_store
+if merge3 is not None:
 
-    def merge_blobs(
-        self,
-        base_blob: Optional[Blob],
-        ours_blob: Optional[Blob],
-        theirs_blob: Optional[Blob],
-    ) -> tuple[bytes, bool]:
-        """Perform three-way merge on blob contents.
-
-        Args:
-            base_blob: Common ancestor blob (can be None)
-            ours_blob: Our version of the blob (can be None)
-            theirs_blob: Their version of the blob (can be None)
-
-        Returns:
-            Tuple of (merged_content, had_conflicts)
-        """
-        if merge3 is None:
-            raise ImportError(
-                "merge3 is required for merging. Install with: pip install dulwich[merge]"
-            )
-
-        # Handle deletion cases
-        if ours_blob is None and theirs_blob is None:
-            return b"", False
-
-        if base_blob is None:
-            # No common ancestor
-            if ours_blob is None:
-                assert theirs_blob is not None
-                return theirs_blob.data, False
-            elif theirs_blob is None:
-                return ours_blob.data, False
-            elif ours_blob.data == theirs_blob.data:
-                return ours_blob.data, False
-            else:
-                # Both added different content - conflict
-                m = merge3.Merge3(
-                    [],
-                    ours_blob.data.splitlines(True),
-                    theirs_blob.data.splitlines(True),
-                )
-                return self._merge3_to_bytes(m), True
-
-        # Get content for each version
-        base_content = base_blob.data if base_blob else b""
-        ours_content = ours_blob.data if ours_blob else b""
-        theirs_content = theirs_blob.data if theirs_blob else b""
-
-        # Check if either side deleted
-        if ours_blob is None or theirs_blob is None:
-            if ours_blob is None and theirs_blob is None:
-                return b"", False
-            elif ours_blob is None:
-                # We deleted, check if they modified
-                if base_content == theirs_content:
-                    return b"", False  # They didn't modify, accept deletion
-                else:
-                    # Conflict: we deleted, they modified
-                    m = merge3.Merge3(
-                        base_content.splitlines(True),
-                        [],
-                        theirs_content.splitlines(True),
-                    )
-                    return self._merge3_to_bytes(m), True
-            else:
-                # They deleted, check if we modified
-                if base_content == ours_content:
-                    return b"", False  # We didn't modify, accept deletion
-                else:
-                    # Conflict: they deleted, we modified
-                    m = merge3.Merge3(
-                        base_content.splitlines(True),
-                        ours_content.splitlines(True),
-                        [],
-                    )
-                    return self._merge3_to_bytes(m), True
-
-        # Both sides exist, check if merge is needed
-        if ours_content == theirs_content:
-            return ours_content, False
-        elif base_content == ours_content:
-            return theirs_content, False
-        elif base_content == theirs_content:
-            return ours_content, False
-
-        # Perform three-way merge
-        m = merge3.Merge3(
-            base_content.splitlines(True),
-            ours_content.splitlines(True),
-            theirs_content.splitlines(True),
-        )
-
-        # Check for conflicts and generate merged content
-        merged_content = self._merge3_to_bytes(m)
-        has_conflicts = b"<<<<<<< ours" in merged_content
-
-        return merged_content, has_conflicts
-
-    def _merge3_to_bytes(self, m: merge3.Merge3) -> bytes:
+    def _merge3_to_bytes(m: merge3.Merge3) -> bytes:
         """Convert merge3 result to bytes with conflict markers.
 
         Args:
@@ -152,8 +60,8 @@ class Merger:
                 base_lines, a_lines, b_lines = group[1], group[2], group[3]
 
                 # Try to merge line by line
-                if self._can_merge_lines(base_lines, a_lines, b_lines):
-                    merged_lines = self._merge_lines(base_lines, a_lines, b_lines)
+                if _can_merge_lines(base_lines, a_lines, b_lines):
+                    merged_lines = _merge_lines(base_lines, a_lines, b_lines)
                     result.extend(merged_lines)
                 else:
                     # Real conflict - add conflict markers
@@ -165,31 +73,141 @@ class Merger:
 
         return b"".join(result)
 
-    def _can_merge_lines(
-        self, base_lines: list[bytes], a_lines: list[bytes], b_lines: list[bytes]
-    ) -> bool:
-        """Check if lines can be merged without conflict."""
-        # If one side is unchanged, we can take the other side
-        if base_lines == a_lines:
-            return True
-        elif base_lines == b_lines:
-            return True
+
+def _merge_lines(
+    base_lines: list[bytes], a_lines: list[bytes], b_lines: list[bytes]
+) -> list[bytes]:
+    """Merge lines when possible."""
+    if base_lines == a_lines:
+        return b_lines
+    elif base_lines == b_lines:
+        return a_lines
+    else:
+        # This shouldn't happen if _can_merge_lines returned True
+        return a_lines
+
+
+def merge_blobs(
+    base_blob: Optional[Blob],
+    ours_blob: Optional[Blob],
+    theirs_blob: Optional[Blob],
+) -> tuple[bytes, bool]:
+    """Perform three-way merge on blob contents.
+
+    Args:
+        base_blob: Common ancestor blob (can be None)
+        ours_blob: Our version of the blob (can be None)
+        theirs_blob: Their version of the blob (can be None)
+
+    Returns:
+        Tuple of (merged_content, had_conflicts)
+    """
+    # Handle deletion cases
+    if ours_blob is None and theirs_blob is None:
+        return b"", False
+
+    if base_blob is None:
+        # No common ancestor
+        if ours_blob is None:
+            assert theirs_blob is not None
+            return theirs_blob.data, False
+        elif theirs_blob is None:
+            return ours_blob.data, False
+        elif ours_blob.data == theirs_blob.data:
+            return ours_blob.data, False
         else:
-            # For now, treat any difference as a conflict
-            # A more sophisticated algorithm would check for non-overlapping changes
-            return False
-
-    def _merge_lines(
-        self, base_lines: list[bytes], a_lines: list[bytes], b_lines: list[bytes]
-    ) -> list[bytes]:
-        """Merge lines when possible."""
-        if base_lines == a_lines:
-            return b_lines
-        elif base_lines == b_lines:
-            return a_lines
+            # Both added different content - conflict
+            m = merge3.Merge3(
+                [],
+                ours_blob.data.splitlines(True),
+                theirs_blob.data.splitlines(True),
+            )
+            return _merge3_to_bytes(m), True
+
+    # Get content for each version
+    base_content = base_blob.data if base_blob else b""
+    ours_content = ours_blob.data if ours_blob else b""
+    theirs_content = theirs_blob.data if theirs_blob else b""
+
+    # Check if either side deleted
+    if ours_blob is None or theirs_blob is None:
+        if ours_blob is None and theirs_blob is None:
+            return b"", False
+        elif ours_blob is None:
+            # We deleted, check if they modified
+            if base_content == theirs_content:
+                return b"", False  # They didn't modify, accept deletion
+            else:
+                # Conflict: we deleted, they modified
+                m = merge3.Merge3(
+                    base_content.splitlines(True),
+                    [],
+                    theirs_content.splitlines(True),
+                )
+                return _merge3_to_bytes(m), True
         else:
-            # This shouldn't happen if _can_merge_lines returned True
-            return a_lines
+            # They deleted, check if we modified
+            if base_content == ours_content:
+                return b"", False  # We didn't modify, accept deletion
+            else:
+                # Conflict: they deleted, we modified
+                m = merge3.Merge3(
+                    base_content.splitlines(True),
+                    ours_content.splitlines(True),
+                    [],
+                )
+                return _merge3_to_bytes(m), True
+
+    # Both sides exist, check if merge is needed
+    if ours_content == theirs_content:
+        return ours_content, False
+    elif base_content == ours_content:
+        return theirs_content, False
+    elif base_content == theirs_content:
+        return ours_content, False
+
+    # Perform three-way merge
+    m = merge3.Merge3(
+        base_content.splitlines(True),
+        ours_content.splitlines(True),
+        theirs_content.splitlines(True),
+    )
+
+    # Check for conflicts and generate merged content
+    merged_content = _merge3_to_bytes(m)
+    has_conflicts = b"<<<<<<< ours" in merged_content
+
+    return merged_content, has_conflicts
+
+
+class Merger:
+    """Handles git merge operations."""
+
+    def __init__(self, object_store: BaseObjectStore):
+        """Initialize merger.
+
+        Args:
+            object_store: Object store to read objects from
+        """
+        self.object_store = object_store
+
+    @staticmethod
+    def merge_blobs(
+        base_blob: Optional[Blob],
+        ours_blob: Optional[Blob],
+        theirs_blob: Optional[Blob],
+    ) -> tuple[bytes, bool]:
+        """Perform three-way merge on blob contents.
+
+        Args:
+            base_blob: Common ancestor blob (can be None)
+            ours_blob: Our version of the blob (can be None)
+            theirs_blob: Their version of the blob (can be None)
+
+        Returns:
+            Tuple of (merged_content, had_conflicts)
+        """
+        return merge_blobs(base_blob, ours_blob, theirs_blob)
 
     def merge_trees(
         self, base_tree: Optional[Tree], ours_tree: Tree, theirs_tree: Tree

+ 4 - 1
dulwich/porcelain.py

@@ -1190,7 +1190,9 @@ def reset(repo, mode, treeish="HEAD") -> None:
             symlink_fn = symlink
         else:
 
-            def symlink_fn(source, target) -> None:  # type: ignore
+            def symlink_fn(  # type: ignore
+                source, target, target_is_directory=False, *, dir_fd=None
+            ) -> None:
                 mode = "w" + ("b" if isinstance(source, bytes) else "")
                 with open(target, mode) as f:
                     f.write(source)
@@ -2219,6 +2221,7 @@ def checkout_branch(repo, target: Union[bytes, str], force: bool = False) -> Non
       force: true or not to force checkout
     """
     import warnings
+
     warnings.warn(
         "checkout_branch is deprecated, use checkout instead.",
         DeprecationWarning,

+ 1 - 0
tests/__init__.py

@@ -136,6 +136,7 @@ def self_test_suite():
         "log_utils",
         "lru_cache",
         "mailmap",
+        "merge",
         "objects",
         "objectspec",
         "object_store",

+ 40 - 2
tests/test_config.py

@@ -515,6 +515,21 @@ class ApplyInsteadOfTests(TestCase):
             "https://samba.org/", apply_instead_of(config, "https://example.com/")
         )
 
+    def test_apply_preserves_case_in_subsection(self) -> None:
+        """Test that mixed-case URLs (like those with access tokens) are preserved."""
+        config = ConfigDict()
+        # GitHub access tokens have mixed case that must be preserved
+        url_with_token = "https://ghp_AbCdEfGhIjKlMnOpQrStUvWxYz1234567890@github.com/"
+        config.set(("url", url_with_token), "insteadOf", "https://github.com/")
+
+        # Apply the substitution
+        result = apply_instead_of(config, "https://github.com/jelmer/dulwich.git")
+        expected = "https://ghp_AbCdEfGhIjKlMnOpQrStUvWxYz1234567890@github.com/jelmer/dulwich.git"
+        self.assertEqual(expected, result)
+
+        # Verify the token case is preserved
+        self.assertIn("ghp_AbCdEfGhIjKlMnOpQrStUvWxYz1234567890", result)
+
 
 class CaseInsensitiveConfigTests(TestCase):
     def test_case_insensitive(self) -> None:
@@ -562,6 +577,25 @@ class CaseInsensitiveConfigTests(TestCase):
         self.assertEqual(1, len(config))
         config[("CORE",)] = "value2"
         self.assertEqual(1, len(config))  # Same key, case insensitive
+
+    def test_subsection_case_preserved(self) -> None:
+        """Test that subsection names preserve their case."""
+        config = CaseInsensitiveOrderedMultiDict()
+        # Section names should be case-insensitive, but subsection names should preserve case
+        config[("url", "https://Example.COM/Path")] = "value1"
+
+        # Can retrieve with different case section name
+        self.assertEqual("value1", config[("URL", "https://Example.COM/Path")])
+        self.assertEqual("value1", config[("url", "https://Example.COM/Path")])
+
+        # But not with different case subsection name
+        with self.assertRaises(KeyError):
+            config[("url", "https://example.com/path")]
+
+        # Verify the stored key preserves subsection case
+        stored_keys = list(config.keys())
+        self.assertEqual(1, len(stored_keys))
+        self.assertEqual(("url", "https://Example.COM/Path"), stored_keys[0])
         config[("other",)] = "value3"
         self.assertEqual(2, len(config))
 
@@ -630,8 +664,12 @@ class CaseInsensitiveConfigTests(TestCase):
     def test_nested_tuple_keys(self) -> None:
         config = CaseInsensitiveOrderedMultiDict()
         config[("branch", "master")] = "value"
-        self.assertEqual("value", config[("BRANCH", "MASTER")])
-        self.assertEqual("value", config[("Branch", "Master")])
+        # Section names are case-insensitive
+        self.assertEqual("value", config[("BRANCH", "master")])
+        self.assertEqual("value", config[("Branch", "master")])
+        # But subsection names are case-sensitive
+        with self.assertRaises(KeyError):
+            config[("branch", "MASTER")]
 
 
 class ConfigFileSetTests(TestCase):

+ 4 - 0
tests/test_merge.py

@@ -1,5 +1,6 @@
 """Tests for merge functionality."""
 
+import importlib.util
 import unittest
 
 from dulwich.merge import MergeConflict, Merger, three_way_merge
@@ -12,6 +13,9 @@ class MergeTests(unittest.TestCase):
 
     def setUp(self):
         self.repo = MemoryRepo()
+        # Check if merge3 module is available
+        if importlib.util.find_spec("merge3") is None:
+            raise unittest.SkipTest("merge3 module not available, skipping merge tests")
         self.merger = Merger(self.repo.object_store)
 
     def test_merge_blobs_no_conflict(self):