Jelajahi Sumber

Fix case-only file renames on case-insensitive filesystems

The update_working_tree function was incorrectly handling case-only
renames (e.g., readme.txt -> README.txt) on case-insensitive filesystems
like macOS and Windows. The two-pass approach (delete first, then add)
caused files to be deleted entirely since both names refer to the same
file on such filesystems.

Extract filesystem-specific path normalization logic into dedicated functions
and use config-based selection for better maintainability. This improves the
case-only rename detection to use proper NTFS/HFS+ normalization instead of
simple lowercasing.
Jelmer Vernooij 1 bulan lalu
induk
melakukan
8b3ac66c47
2 mengubah file dengan 555 tambahan dan 83 penghapusan
  1. 196 77
      dulwich/index.py
  2. 359 6
      tests/test_index.py

+ 196 - 77
dulwich/index.py

@@ -42,6 +42,7 @@ from typing import (
 )
 
 if TYPE_CHECKING:
+    from .config import Config
     from .diff_tree import TreeChange
     from .file import _GitFile
     from .line_ending import BlobNormalizer
@@ -1299,15 +1300,59 @@ def build_file_from_blob(
 INVALID_DOTNAMES = (b".git", b".", b"..", b"")
 
 
+def _normalize_path_element_default(element: bytes) -> bytes:
+    """Normalize path element for default case-insensitive comparison."""
+    return element.lower()
+
+
+def _normalize_path_element_ntfs(element: bytes) -> bytes:
+    """Normalize path element for NTFS filesystem."""
+    return element.rstrip(b". ").lower()
+
+
+def _normalize_path_element_hfs(element: bytes) -> bytes:
+    """Normalize path element for HFS+ filesystem."""
+    import unicodedata
+
+    # Decode to Unicode (let UnicodeDecodeError bubble up)
+    element_str = element.decode("utf-8", errors="strict")
+
+    # Remove HFS+ ignorable characters
+    filtered = "".join(c for c in element_str if ord(c) not in HFS_IGNORABLE_CHARS)
+    # Normalize to NFD
+    normalized = unicodedata.normalize("NFD", filtered)
+    return normalized.lower().encode("utf-8", errors="strict")
+
+
+def get_path_element_normalizer(config) -> Callable[[bytes], bytes]:
+    """Get the appropriate path element normalization function based on config.
+
+    Args:
+        config: Repository configuration object
+
+    Returns:
+        Function that normalizes path elements for the configured filesystem
+    """
+    import os
+    import sys
+
+    if config.get_boolean(b"core", b"protectNTFS", os.name == "nt"):
+        return _normalize_path_element_ntfs
+    elif config.get_boolean(b"core", b"protectHFS", sys.platform == "darwin"):
+        return _normalize_path_element_hfs
+    else:
+        return _normalize_path_element_default
+
+
 def validate_path_element_default(element: bytes) -> bool:
-    return element.lower() not in INVALID_DOTNAMES
+    return _normalize_path_element_default(element) not in INVALID_DOTNAMES
 
 
 def validate_path_element_ntfs(element: bytes) -> bool:
-    stripped = element.rstrip(b". ").lower()
-    if stripped in INVALID_DOTNAMES:
+    normalized = _normalize_path_element_ntfs(element)
+    if normalized in INVALID_DOTNAMES:
         return False
-    if stripped == b"git~1":
+    if normalized == b"git~1":
         return False
     return True
 
@@ -1339,28 +1384,18 @@ def validate_path_element_hfs(element: bytes) -> bool:
     Equivalent to Git's is_hfs_dotgit and related checks.
     Uses NFD normalization and ignores HFS+ ignorable characters.
     """
-    import unicodedata
-
     try:
-        # Decode to Unicode
-        element_str = element.decode("utf-8", errors="strict")
+        normalized = _normalize_path_element_hfs(element)
     except UnicodeDecodeError:
         # Malformed UTF-8 - be conservative and reject
         return False
 
-    # Remove HFS+ ignorable characters (like Git's next_hfs_char)
-    filtered = "".join(c for c in element_str if ord(c) not in HFS_IGNORABLE_CHARS)
-
-    # Normalize to NFD (HFS+ uses a variant of NFD)
-    normalized = unicodedata.normalize("NFD", filtered)
-
-    # Check against invalid names (case-insensitive)
-    normalized_bytes = normalized.encode("utf-8", errors="strict")
-    if normalized_bytes.lower() in INVALID_DOTNAMES:
+    # Check against invalid names
+    if normalized in INVALID_DOTNAMES:
         return False
 
     # Also check for 8.3 short name
-    if normalized_bytes.lower() == b"git~1":
+    if normalized == b"git~1":
         return False
 
     return True
@@ -1837,6 +1872,130 @@ def _transition_to_absent(repo, path, full_path, current_stat, index):
     )
 
 
+def detect_case_only_renames(
+    changes: list["TreeChange"],
+    config: "Config",
+) -> list["TreeChange"]:
+    """Detect and transform case-only renames in a list of tree changes.
+
+    This function identifies file renames that only differ in case (e.g.,
+    README.txt -> readme.txt) and transforms matching ADD/DELETE pairs into
+    CHANGE_RENAME operations. It uses filesystem-appropriate path normalization
+    based on the repository configuration.
+
+    Args:
+      changes: List of TreeChange objects representing file changes
+      config: Repository configuration object
+
+    Returns:
+      New list of TreeChange objects with case-only renames converted to CHANGE_RENAME
+    """
+    from .diff_tree import (
+        CHANGE_ADD,
+        CHANGE_COPY,
+        CHANGE_DELETE,
+        CHANGE_MODIFY,
+        CHANGE_RENAME,
+        TreeChange,
+    )
+
+    # Build dictionaries of old and new paths with their normalized forms
+    old_paths_normalized = {}
+    new_paths_normalized = {}
+    old_changes = {}  # Map from old path to change object
+    new_changes = {}  # Map from new path to change object
+
+    # Get the appropriate normalizer based on config
+    normalize_func = get_path_element_normalizer(config)
+
+    def normalize_path(path: bytes) -> bytes:
+        """Normalize entire path using element normalization."""
+        return b"/".join(normalize_func(part) for part in path.split(b"/"))
+
+    # Pre-normalize all paths once to avoid repeated normalization
+    for change in changes:
+        if change.type == CHANGE_DELETE and change.old:
+            try:
+                normalized = normalize_path(change.old.path)
+            except UnicodeDecodeError:
+                import logging
+
+                logging.warning(
+                    "Skipping case-only rename detection for path with invalid UTF-8: %r",
+                    change.old.path,
+                )
+            else:
+                old_paths_normalized[normalized] = change.old.path
+                old_changes[change.old.path] = change
+        elif change.type == CHANGE_RENAME and change.old:
+            # Treat RENAME as DELETE + ADD for case-only detection
+            try:
+                normalized = normalize_path(change.old.path)
+            except UnicodeDecodeError:
+                import logging
+
+                logging.warning(
+                    "Skipping case-only rename detection for path with invalid UTF-8: %r",
+                    change.old.path,
+                )
+            else:
+                old_paths_normalized[normalized] = change.old.path
+                old_changes[change.old.path] = change
+
+        if (
+            change.type in (CHANGE_ADD, CHANGE_MODIFY, CHANGE_RENAME, CHANGE_COPY)
+            and change.new
+        ):
+            try:
+                normalized = normalize_path(change.new.path)
+            except UnicodeDecodeError:
+                import logging
+
+                logging.warning(
+                    "Skipping case-only rename detection for path with invalid UTF-8: %r",
+                    change.new.path,
+                )
+            else:
+                new_paths_normalized[normalized] = change.new.path
+                new_changes[change.new.path] = change
+
+    # Find case-only renames and transform changes
+    case_only_renames = set()
+    new_rename_changes = []
+
+    for norm_path, old_path in old_paths_normalized.items():
+        if norm_path in new_paths_normalized:
+            new_path = new_paths_normalized[norm_path]
+            if old_path != new_path:
+                # Found a case-only rename
+                old_change = old_changes[old_path]
+                new_change = new_changes[new_path]
+
+                # Create a CHANGE_RENAME to replace the DELETE and ADD/MODIFY pair
+                if new_change.type == CHANGE_ADD:
+                    # Simple case: DELETE + ADD becomes RENAME
+                    rename_change = TreeChange(
+                        CHANGE_RENAME, old_change.old, new_change.new
+                    )
+                else:
+                    # Complex case: DELETE + MODIFY becomes RENAME
+                    # Use the old file from DELETE and new file from MODIFY
+                    rename_change = TreeChange(
+                        CHANGE_RENAME, old_change.old, new_change.new
+                    )
+
+                new_rename_changes.append(rename_change)
+
+                # Mark the old changes for removal
+                case_only_renames.add(old_change)
+                case_only_renames.add(new_change)
+
+    # Return new list with original ADD/DELETE changes replaced by renames
+    result = [change for change in changes if change not in case_only_renames]
+    result.extend(new_rename_changes)
+    return result
+
+
 def update_working_tree(
     repo: "Repo",
     old_tree_id: Optional[bytes],
@@ -1892,6 +2051,17 @@ def update_working_tree(
     # Convert iterator to list since we need multiple passes
     changes = list(change_iterator)
 
+    # Transform case-only renames on case-insensitive filesystems
+    import platform
+
+    default_ignore_case = platform.system() in ("Windows", "Darwin")
+    config = repo.get_config()
+    ignore_case = config.get_boolean((b"core",), b"ignorecase", default_ignore_case)
+
+    if ignore_case:
+        config = repo.get_config()
+        changes = detect_case_only_renames(changes, config)
+
     # Check for path conflicts where files need to become directories
     paths_becoming_dirs = set()
     for change in changes:
@@ -1996,7 +2166,7 @@ def update_working_tree(
 
     # Apply the changes
     for change in changes:
-        if change.type == CHANGE_DELETE:
+        if change.type in (CHANGE_DELETE, CHANGE_RENAME):
             # Remove file/directory
             path = change.old.path
             if path.startswith(b".git") or not validate_path(
@@ -2016,7 +2186,13 @@ def update_working_tree(
 
             _transition_to_absent(repo, path, full_path, delete_stat, index)
 
-        elif change.type in (CHANGE_ADD, CHANGE_MODIFY, CHANGE_UNCHANGED):
+        if change.type in (
+            CHANGE_ADD,
+            CHANGE_MODIFY,
+            CHANGE_UNCHANGED,
+            CHANGE_COPY,
+            CHANGE_RENAME,
+        ):
             # Add or modify file
             path = change.new.path
             if path.startswith(b".git") or not validate_path(
@@ -2052,63 +2228,6 @@ def update_working_tree(
                     tree_encoding,
                 )
 
-        elif change.type in (CHANGE_RENAME, CHANGE_COPY):
-            # Handle rename/copy: remove old, add new
-            old_path = change.old.path
-            new_path = change.new.path
-
-            if not old_path.startswith(b".git") and validate_path(
-                old_path, validate_path_element
-            ):
-                old_full_path = _tree_to_fs_path(repo_path, old_path, tree_encoding)
-                try:
-                    old_current_stat = os.lstat(old_full_path)
-                except FileNotFoundError:
-                    old_current_stat = None
-                except OSError as e:
-                    raise OSError(
-                        f"Cannot access {old_path.decode('utf-8', errors='replace')}: {e}"
-                    ) from e
-                _transition_to_absent(
-                    repo, old_path, old_full_path, old_current_stat, index
-                )
-
-            if not new_path.startswith(b".git") and validate_path(
-                new_path, validate_path_element
-            ):
-                new_full_path = _tree_to_fs_path(repo_path, new_path, tree_encoding)
-                try:
-                    new_current_stat = os.lstat(new_full_path)
-                except FileNotFoundError:
-                    new_current_stat = None
-                except OSError as e:
-                    raise OSError(
-                        f"Cannot access {new_path.decode('utf-8', errors='replace')}: {e}"
-                    ) from e
-
-                if S_ISGITLINK(change.new.mode):
-                    _transition_to_submodule(
-                        repo,
-                        new_path,
-                        new_full_path,
-                        new_current_stat,
-                        change.new,
-                        index,
-                    )
-                else:
-                    _transition_to_file(
-                        repo.object_store,
-                        new_path,
-                        new_full_path,
-                        new_current_stat,
-                        change.new,
-                        index,
-                        honor_filemode,
-                        symlink_fn,
-                        blob_normalizer,
-                        tree_encoding,
-                    )
-
     index.write()
 
 

+ 359 - 6
tests/test_index.py

@@ -29,7 +29,16 @@ import sys
 import tempfile
 from io import BytesIO
 
-from dulwich.diff_tree import tree_changes
+from dulwich.config import ConfigDict
+from dulwich.diff_tree import (
+    CHANGE_ADD,
+    CHANGE_COPY,
+    CHANGE_DELETE,
+    CHANGE_MODIFY,
+    CHANGE_RENAME,
+    TreeChange,
+    tree_changes,
+)
 from dulwich.index import (
     Index,
     IndexEntry,
@@ -43,6 +52,7 @@ from dulwich.index import (
     build_index_from_tree,
     cleanup_mode,
     commit_tree,
+    detect_case_only_renames,
     get_unstaged_changes,
     index_entry_from_directory,
     index_entry_from_path,
@@ -59,7 +69,7 @@ from dulwich.index import (
     write_index_dict,
 )
 from dulwich.object_store import MemoryObjectStore
-from dulwich.objects import S_IFGITLINK, Blob, Commit, Tree
+from dulwich.objects import S_IFGITLINK, Blob, Commit, Tree, TreeEntry
 from dulwich.repo import Repo
 
 from . import TestCase, skipIf
@@ -1732,6 +1742,254 @@ class TestPathPrefixCompression(TestCase):
         self.assertEqual(b"short", decompressed)
 
 
+class TestDetectCaseOnlyRenames(TestCase):
+    """Tests for detect_case_only_renames function."""
+
+    def setUp(self):
+        self.config = ConfigDict()
+
+    def test_no_renames(self):
+        """Test when there are no renames."""
+        changes = [
+            TreeChange(
+                CHANGE_DELETE,
+                TreeEntry(b"file1.txt", 0o100644, b"a" * 40),
+                None,
+            ),
+            TreeChange(
+                CHANGE_ADD,
+                None,
+                TreeEntry(b"file2.txt", 0o100644, b"b" * 40),
+            ),
+        ]
+
+        result = detect_case_only_renames(changes, self.config)
+        # No case-only renames, so should return original changes
+        self.assertEqual(changes, result)
+
+    def test_simple_case_rename(self):
+        """Test simple case-only rename detection."""
+        # Default config uses case-insensitive comparison
+        changes = [
+            TreeChange(
+                CHANGE_DELETE,
+                TreeEntry(b"README.txt", 0o100644, b"a" * 40),
+                None,
+            ),
+            TreeChange(
+                CHANGE_ADD,
+                None,
+                TreeEntry(b"readme.txt", 0o100644, b"a" * 40),
+            ),
+        ]
+
+        result = detect_case_only_renames(changes, self.config)
+        # Should return one CHANGE_RENAME instead of ADD/DELETE pair
+        self.assertEqual(1, len(result))
+        self.assertEqual(CHANGE_RENAME, result[0].type)
+        self.assertEqual(b"README.txt", result[0].old.path)
+        self.assertEqual(b"readme.txt", result[0].new.path)
+
+    def test_nested_path_case_rename(self):
+        """Test case-only rename in nested paths."""
+        changes = [
+            TreeChange(
+                CHANGE_DELETE,
+                TreeEntry(b"src/Main.java", 0o100644, b"a" * 40),
+                None,
+            ),
+            TreeChange(
+                CHANGE_ADD,
+                None,
+                TreeEntry(b"src/main.java", 0o100644, b"a" * 40),
+            ),
+        ]
+
+        result = detect_case_only_renames(changes, self.config)
+        # Should return one CHANGE_RENAME instead of ADD/DELETE pair
+        self.assertEqual(1, len(result))
+        self.assertEqual(CHANGE_RENAME, result[0].type)
+        self.assertEqual(b"src/Main.java", result[0].old.path)
+        self.assertEqual(b"src/main.java", result[0].new.path)
+
+    def test_multiple_case_renames(self):
+        """Test multiple case-only renames."""
+        changes = [
+            TreeChange(
+                CHANGE_DELETE,
+                TreeEntry(b"File1.txt", 0o100644, b"a" * 40),
+                None,
+            ),
+            TreeChange(
+                CHANGE_DELETE,
+                TreeEntry(b"File2.TXT", 0o100644, b"b" * 40),
+                None,
+            ),
+            TreeChange(
+                CHANGE_ADD,
+                None,
+                TreeEntry(b"file1.txt", 0o100644, b"a" * 40),
+            ),
+            TreeChange(
+                CHANGE_ADD,
+                None,
+                TreeEntry(b"file2.txt", 0o100644, b"b" * 40),
+            ),
+        ]
+
+        result = detect_case_only_renames(changes, self.config)
+        # Should return two CHANGE_RENAME instead of ADD/DELETE pairs
+        self.assertEqual(2, len(result))
+        rename_changes = [c for c in result if c.type == CHANGE_RENAME]
+        self.assertEqual(2, len(rename_changes))
+        # Check that the renames are correct (order may vary)
+        rename_map = {c.old.path: c.new.path for c in rename_changes}
+        self.assertEqual(
+            {b"File1.txt": b"file1.txt", b"File2.TXT": b"file2.txt"}, rename_map
+        )
+
+    def test_case_rename_with_modify(self):
+        """Test case rename detection with CHANGE_MODIFY."""
+        changes = [
+            TreeChange(
+                CHANGE_DELETE,
+                TreeEntry(b"README.md", 0o100644, b"a" * 40),
+                None,
+            ),
+            TreeChange(
+                CHANGE_MODIFY,
+                TreeEntry(b"readme.md", 0o100644, b"a" * 40),
+                TreeEntry(b"readme.md", 0o100644, b"b" * 40),
+            ),
+        ]
+
+        result = detect_case_only_renames(changes, self.config)
+        # Should return one CHANGE_RENAME instead of DELETE/MODIFY pair
+        self.assertEqual(1, len(result))
+        self.assertEqual(CHANGE_RENAME, result[0].type)
+        self.assertEqual(b"README.md", result[0].old.path)
+        self.assertEqual(b"readme.md", result[0].new.path)
+
+    def test_hfs_normalization(self):
+        """Test case rename detection with HFS+ normalization."""
+        # Configure for HFS+ (macOS)
+        self.config.set((b"core",), b"protectHFS", b"true")
+        self.config.set((b"core",), b"protectNTFS", b"false")
+
+        # Test with composed vs decomposed Unicode
+        changes = [
+            TreeChange(
+                CHANGE_DELETE,
+                TreeEntry("café.txt".encode(), 0o100644, b"a" * 40),
+                None,
+            ),
+            TreeChange(
+                CHANGE_ADD,
+                None,
+                TreeEntry("CAFÉ.txt".encode(), 0o100644, b"a" * 40),
+            ),
+        ]
+
+        result = detect_case_only_renames(changes, self.config)
+
+        # Should return one CHANGE_RENAME for the case-only rename
+        self.assertEqual(1, len(result))
+        self.assertEqual(CHANGE_RENAME, result[0].type)
+        self.assertEqual("café.txt".encode(), result[0].old.path)
+        self.assertEqual("CAFÉ.txt".encode(), result[0].new.path)
+
+    def test_ntfs_normalization(self):
+        """Test case rename detection with NTFS normalization."""
+        # Configure for NTFS (Windows)
+        self.config.set((b"core",), b"protectNTFS", b"true")
+        self.config.set((b"core",), b"protectHFS", b"false")
+
+        # NTFS strips trailing dots and spaces
+        changes = [
+            TreeChange(
+                CHANGE_DELETE,
+                TreeEntry(b"file.txt.", 0o100644, b"a" * 40),
+                None,
+            ),
+            TreeChange(
+                CHANGE_ADD,
+                None,
+                TreeEntry(b"FILE.TXT", 0o100644, b"a" * 40),
+            ),
+        ]
+
+        result = detect_case_only_renames(changes, self.config)
+        # Should return one CHANGE_RENAME for the case-only rename
+        self.assertEqual(1, len(result))
+        self.assertEqual(CHANGE_RENAME, result[0].type)
+        self.assertEqual(b"file.txt.", result[0].old.path)
+        self.assertEqual(b"FILE.TXT", result[0].new.path)
+
+    def test_invalid_utf8_handling(self):
+        """Test handling of invalid UTF-8 in paths."""
+        # Invalid UTF-8 sequence
+        invalid_path = b"\xff\xfe"
+
+        changes = [
+            TreeChange(
+                CHANGE_DELETE,
+                TreeEntry(invalid_path, 0o100644, b"a" * 40),
+                None,
+            ),
+            TreeChange(
+                CHANGE_ADD,
+                None,
+                TreeEntry(b"valid.txt", 0o100644, b"b" * 40),
+            ),
+        ]
+
+        # Should not crash, just skip invalid paths
+        result = detect_case_only_renames(changes, self.config)
+        # No case-only renames detected, returns original changes
+        self.assertEqual(changes, result)
+
+    def test_rename_and_copy_changes(self):
+        """Test case rename detection with CHANGE_RENAME and CHANGE_COPY."""
+        changes = [
+            TreeChange(
+                CHANGE_DELETE,
+                TreeEntry(b"OldFile.txt", 0o100644, b"a" * 40),
+                None,
+            ),
+            TreeChange(
+                CHANGE_RENAME,
+                TreeEntry(b"other.txt", 0o100644, b"b" * 40),
+                TreeEntry(b"oldfile.txt", 0o100644, b"a" * 40),
+            ),
+            TreeChange(
+                CHANGE_COPY,
+                TreeEntry(b"source.txt", 0o100644, b"c" * 40),
+                TreeEntry(b"OLDFILE.TXT", 0o100644, b"a" * 40),
+            ),
+        ]
+
+        result = detect_case_only_renames(changes, self.config)
+        # The DELETE of OldFile.txt and COPY to OLDFILE.TXT are detected as a case-only rename
+        # The original RENAME (other.txt -> oldfile.txt) remains
+        # The COPY is consumed by the case-only rename detection
+        self.assertEqual(2, len(result))
+
+        # Find the changes
+        rename_changes = [c for c in result if c.type == CHANGE_RENAME]
+        self.assertEqual(2, len(rename_changes))
+
+        # Check for the case-only rename
+        case_rename = None
+        for change in rename_changes:
+            if change.old.path == b"OldFile.txt" and change.new.path == b"OLDFILE.TXT":
+                case_rename = change
+                break
+
+        self.assertIsNotNone(case_rename)
+        self.assertEqual(b"OldFile.txt", case_rename.old.path)
+        self.assertEqual(b"OLDFILE.TXT", case_rename.new.path)
+
+
 class TestUpdateWorkingTree(TestCase):
     def setUp(self):
         self.tempdir = tempfile.mkdtemp()
@@ -2225,6 +2483,19 @@ class TestUpdateWorkingTree(TestCase):
 
     def test_update_working_tree_case_sensitivity(self):
         """Test handling of case-sensitive filename changes."""
+        # Detect if filesystem is case-insensitive by testing
+        test_file = os.path.join(self.tempdir, "TeSt.tmp")
+        with open(test_file, "w") as f:
+            f.write("test")
+        is_case_insensitive = os.path.exists(os.path.join(self.tempdir, "test.tmp"))
+        os.unlink(test_file)
+
+        # Set core.ignorecase to match actual filesystem behavior
+        # (This ensures test works correctly regardless of platform defaults)
+        config = self.repo.get_config()
+        config.set((b"core",), b"ignorecase", is_case_insensitive)
+        config.write_to_path()
+
         # Create tree with lowercase file
         blob1 = Blob()
         blob1.data = b"lowercase content"
@@ -2255,12 +2526,94 @@ class TestUpdateWorkingTree(TestCase):
         lowercase_path = os.path.join(self.tempdir, "readme.txt")
         uppercase_path = os.path.join(self.tempdir, "README.txt")
 
-        # On case-insensitive filesystems, one will overwrite the other
-        # On case-sensitive filesystems, both may exist
-        self.assertTrue(
-            os.path.exists(lowercase_path) or os.path.exists(uppercase_path)
+        if is_case_insensitive:
+            # On case-insensitive filesystems, should have one file with new content
+            # The exact case of the filename may vary by OS
+            self.assertTrue(
+                os.path.exists(lowercase_path) or os.path.exists(uppercase_path)
+            )
+            # Verify content is the new content
+            if os.path.exists(lowercase_path):
+                with open(lowercase_path, "rb") as f:
+                    self.assertEqual(b"uppercase content", f.read())
+            else:
+                with open(uppercase_path, "rb") as f:
+                    self.assertEqual(b"uppercase content", f.read())
+        else:
+            # On case-sensitive filesystems, only the uppercase file should exist
+            self.assertFalse(os.path.exists(lowercase_path))
+            self.assertTrue(os.path.exists(uppercase_path))
+            with open(uppercase_path, "rb") as f:
+                self.assertEqual(b"uppercase content", f.read())
+
+    def test_update_working_tree_case_rename_updates_filename(self):
+        """Test that case-only renames update the actual filename on case-insensitive FS."""
+        # Detect if filesystem is case-insensitive by testing
+        test_file = os.path.join(self.tempdir, "TeSt.tmp")
+        with open(test_file, "w") as f:
+            f.write("test")
+        is_case_insensitive = os.path.exists(os.path.join(self.tempdir, "test.tmp"))
+        os.unlink(test_file)
+
+        if not is_case_insensitive:
+            self.skipTest("Test only relevant on case-insensitive filesystems")
+
+        # Set core.ignorecase to match actual filesystem behavior
+        config = self.repo.get_config()
+        config.set((b"core",), b"ignorecase", True)
+        config.write_to_path()
+
+        # Create tree with lowercase file
+        blob1 = Blob()
+        blob1.data = b"same content"  # Using same content to test pure case rename
+        self.repo.object_store.add_object(blob1)
+
+        tree1 = Tree()
+        tree1[b"readme.txt"] = (0o100644, blob1.id)
+        self.repo.object_store.add_object(tree1)
+
+        # Update to tree1
+        changes = tree_changes(self.repo.object_store, None, tree1.id)
+        update_working_tree(self.repo, None, tree1.id, change_iterator=changes)
+
+        # Verify initial state
+        files = [f for f in os.listdir(self.tempdir) if not f.startswith(".git")]
+        self.assertEqual(["readme.txt"], files)
+
+        # Create tree with uppercase file (same content, same blob)
+        tree2 = Tree()
+        tree2[b"README.txt"] = (0o100644, blob1.id)  # Same blob!
+        self.repo.object_store.add_object(tree2)
+
+        # Update to tree2 (case-only rename)
+        changes = tree_changes(self.repo.object_store, tree1.id, tree2.id)
+        update_working_tree(self.repo, tree1.id, tree2.id, change_iterator=changes)
+
+        # On case-insensitive filesystems, should have one file with updated case
+        files = [f for f in os.listdir(self.tempdir) if not f.startswith(".git")]
+        self.assertEqual(
+            1, len(files), "Should have exactly one file after case rename"
+        )
+
+        # The file should now have the new case in the directory listing
+        actual_filename = files[0]
+        self.assertEqual(
+            "README.txt",
+            actual_filename,
+            "Filename case should be updated in directory listing",
         )
 
+        # Verify content is preserved
+        file_path = os.path.join(self.tempdir, actual_filename)
+        with open(file_path, "rb") as f:
+            self.assertEqual(b"same content", f.read())
+
+        # Both old and new case should access the same file
+        lowercase_path = os.path.join(self.tempdir, "readme.txt")
+        uppercase_path = os.path.join(self.tempdir, "README.txt")
+        self.assertTrue(os.path.exists(lowercase_path))
+        self.assertTrue(os.path.exists(uppercase_path))
+
     def test_update_working_tree_deeply_nested_removal(self):
         """Test removal of deeply nested directory structures."""
         # Create deeply nested structure