فهرست منبع

Use nanosecond-resolution when comparing file entries (#2013)

Jelmer Vernooij 1 ماه پیش
والد
کامیت
6503f53972
3فایلهای تغییر یافته به همراه97 افزوده شده و 9 حذف شده
  1. 41 9
      dulwich/index.py
  2. 45 0
      tests/test_index.py
  3. 11 0
      tests/test_porcelain.py

+ 41 - 9
dulwich/index.py

@@ -1629,9 +1629,31 @@ def index_entry_from_stat(
 
 
     from dulwich.objects import ObjectID
     from dulwich.objects import ObjectID
 
 
+    # Use nanosecond precision when available to avoid precision loss
+    # through float representation
+    ctime: int | float | tuple[int, int]
+    mtime: int | float | tuple[int, int]
+    st_ctime_ns = getattr(stat_val, "st_ctime_ns", None)
+    if st_ctime_ns is not None:
+        ctime = (
+            st_ctime_ns // 1_000_000_000,
+            st_ctime_ns % 1_000_000_000,
+        )
+    else:
+        ctime = stat_val.st_ctime
+
+    st_mtime_ns = getattr(stat_val, "st_mtime_ns", None)
+    if st_mtime_ns is not None:
+        mtime = (
+            st_mtime_ns // 1_000_000_000,
+            st_mtime_ns % 1_000_000_000,
+        )
+    else:
+        mtime = stat_val.st_mtime
+
     return IndexEntry(
     return IndexEntry(
-        ctime=stat_val.st_ctime,
-        mtime=stat_val.st_mtime,
+        ctime=ctime,
+        mtime=mtime,
         dev=stat_val.st_dev,
         dev=stat_val.st_dev,
         ino=stat_val.st_ino,
         ino=stat_val.st_ino,
         mode=mode,
         mode=mode,
@@ -2773,17 +2795,27 @@ def _stat_matches_entry(st: os.stat_result, entry: IndexEntry) -> bool:
       entry: Index entry to compare against
       entry: Index entry to compare against
     Returns: True if stat matches and file is likely unchanged
     Returns: True if stat matches and file is likely unchanged
     """
     """
-    # Get entry mtime
+    # Get entry mtime with nanosecond precision if available
     if isinstance(entry.mtime, tuple):
     if isinstance(entry.mtime, tuple):
         entry_mtime_sec = entry.mtime[0]
         entry_mtime_sec = entry.mtime[0]
+        entry_mtime_nsec = entry.mtime[1]
     else:
     else:
         entry_mtime_sec = int(entry.mtime)
         entry_mtime_sec = int(entry.mtime)
-
-    # Compare modification time (seconds only for now)
-    # Note: We use int() to compare only seconds, as nanosecond precision
-    # can vary across filesystems
-    if int(st.st_mtime) != entry_mtime_sec:
-        return False
+        entry_mtime_nsec = 0
+
+    # Compare modification time with nanosecond precision if available
+    # This is important for fast workflows (e.g., stash) where files can be
+    # modified multiple times within the same second
+    if hasattr(st, "st_mtime_ns"):
+        # Use nanosecond precision when available
+        st_mtime_nsec = st.st_mtime_ns
+        entry_mtime_nsec_total = entry_mtime_sec * 1_000_000_000 + entry_mtime_nsec
+        if st_mtime_nsec != entry_mtime_nsec_total:
+            return False
+    else:
+        # Fall back to second precision
+        if int(st.st_mtime) != entry_mtime_sec:
+            return False
 
 
     # Compare file size
     # Compare file size
     if st.st_size != entry.size:
     if st.st_size != entry.size:

+ 45 - 0
tests/test_index.py

@@ -908,6 +908,51 @@ class GetUnstagedChangesTests(TestCase):
             self.assertEqual(changes_serial, changes_parallel)
             self.assertEqual(changes_serial, changes_parallel)
             self.assertEqual(changes_serial, sorted(modified_files))
             self.assertEqual(changes_serial, sorted(modified_files))
 
 
+    def test_get_unstaged_changes_nanosecond_precision(self) -> None:
+        """Test that nanosecond precision mtime is used for change detection."""
+        repo_dir = tempfile.mkdtemp()
+        self.addCleanup(shutil.rmtree, repo_dir)
+        with Repo.init(repo_dir) as repo:
+            # Commit a file
+            foo_fullpath = os.path.join(repo_dir, "foo")
+            with open(foo_fullpath, "wb") as f:
+                f.write(b"original content")
+
+            repo.get_worktree().stage(["foo"])
+            repo.get_worktree().commit(
+                message=b"initial commit",
+                committer=b"committer <email>",
+                author=b"author <email>",
+            )
+
+            # Get the current index entry
+            index = repo.open_index()
+            entry = index[b"foo"]
+
+            # Modify the file with the same size but different content
+            # This simulates a very fast change within the same second
+            with open(foo_fullpath, "wb") as f:
+                f.write(b"modified content")
+
+            # Set mtime to match the index entry exactly (same second)
+            # but with different nanoseconds if the filesystem supports it
+            st = os.stat(foo_fullpath)
+            if isinstance(entry.mtime, tuple) and hasattr(st, "st_mtime_ns"):
+                # Set the mtime to the same second as the index entry
+                # but with a slightly different nanosecond value
+                entry_sec = entry.mtime[0]
+                entry_nsec = entry.mtime[1]
+                new_mtime_ns = entry_sec * 1_000_000_000 + entry_nsec + 1000
+                new_mtime = new_mtime_ns / 1_000_000_000
+                os.utime(foo_fullpath, (st.st_atime, new_mtime))
+
+                # The file should be detected as changed due to nanosecond difference
+                changes = list(get_unstaged_changes(repo.open_index(), repo_dir))
+                self.assertEqual(changes, [b"foo"])
+            else:
+                # If nanosecond precision is not available, skip this test
+                self.skipTest("Nanosecond precision not available on this system")
+
     def test_get_unstaged_deleted_changes(self) -> None:
     def test_get_unstaged_deleted_changes(self) -> None:
         """Unit test for get_unstaged_changes."""
         """Unit test for get_unstaged_changes."""
         repo_dir = tempfile.mkdtemp()
         repo_dir = tempfile.mkdtemp()

+ 11 - 0
tests/test_porcelain.py

@@ -6207,6 +6207,17 @@ class StatusTests(PorcelainTestCase):
             committer=b"committer <email>",
             committer=b"committer <email>",
         )
         )
 
 
+        # Get the index entry mtime and set the file mtime to match it exactly
+        # This ensures stat matching works correctly with nanosecond precision
+        index = self.repo.open_index()
+        entry = index[b"crlf-exists"]
+        if isinstance(entry.mtime, tuple):
+            mtime_nsec = entry.mtime[0] * 1_000_000_000 + entry.mtime[1]
+        else:
+            mtime_nsec = int(entry.mtime * 1_000_000_000)
+        # Use ns parameter to preserve nanosecond precision
+        os.utime(file_path, ns=(mtime_nsec, mtime_nsec))
+
         c = self.repo.get_config()
         c = self.repo.get_config()
         c.set("core", "autocrlf", "input")
         c.set("core", "autocrlf", "input")
         c.write_to_path()
         c.write_to_path()