Repository: jelmer/dulwich

Issue:
type hint and example out-of-sync
The `target` parameter of the `GitClient.fetch` method is `Repo`:

https://github.com/jelmer/dulwich/blob/50df0ca4ff7f009298fdb2801e0771a89d304c02/dulwich/client.py#L925

and `porcelain.fetch` calls the above function with the `repo` parameter of `porcelain.fetch`

https://github.com/jelmer/dulwich/blob/50df0ca4ff7f009298fdb2801e0771a89d304c02/dulwich/porcelain.py#L1750

(via `open_repo_closing` which in this case is a no-op passthrough).

But the `examples/memoryrepo.py` is using a `MemoryRepo` argument for the `target` parameter:

https://github.com/jelmer/dulwich/blob/50df0ca4ff7f009298fdb2801e0771a89d304c02/examples/memoryrepo.py#L20

but `MemoryRepo` is not a subclass of `Repo`, only a `BaseRepo`. So this `MemoryRepo` object will get passed down into `target` which is typed to a `Repo`.

Not sure if this is an issue with the type hint or whether the example is showing something that sometimes fails at runtime.

Is it safe to use a MemoryRepo with GitClient.fetch?

Discussion:
@castedo: Hmmmm, sorry I've messed up my description or I'm confused. I'm going to close this and then re-open if I find something real.@castedo: OK, I've updated the description of this issue to include a missing step. Sorry about the confusion.

I'm mainly just wondering if MemoryRepo is safe to use with GitClient.fetch.

And this might also be a type hint that needs updating.@jelmer: It *should* be safe to use MemoryRepo with GitClient.fetch, and the typehint should just be updated.

I don't think we have any explicit tests today that try to use GitClient.fetch with MemoryRepo though.@jelmer: Can you send a PR to fix the type hint?@castedo: > Can you send a PR to fix the type hint?

OK, sounds good.

Solution patch:
diff --git a/dulwich/config.py b/dulwich/config.py
index 5e563094..93507b6e 100644
--- a/dulwich/config.py
+++ b/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
 


Test patch:
diff --git a/tests/test_config.py b/tests/test_config.py
index 9d73e779..06e4eb7b 100644
--- a/tests/test_config.py
+++ b/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):