Просмотр исходного кода

Implement advanced Git object specification support

Add support for two advanced Git object specification syntaxes:

1. Index path lookup (:path) - Access files from the Git index at
   specific merge conflict stages:
   - :path or :0:path - stage 0 (current index)
   - :1:path - stage 1 (common ancestor)
   - :2:path - stage 2 (current branch/this)
   - :3:path - stage 3 (other branch)

2. Reflog time specifications (@{time}) - Query repository state at
   specific points in time using Git's approxidate format:
   - HEAD@{yesterday}
   - master@{2.weeks.ago}
   - @{1234567890} (Unix timestamp)
   - @{2009-02-13} (ISO date)

Fixes #1783
Jelmer Vernooij 2 месяцев назад
Родитель
Сommit
f6ee40f71d
8 измененных файлов с 592 добавлено и 183 удалено
  1. 5 0
      NEWS
  2. 161 0
      dulwich/approxidate.py
  3. 9 63
      dulwich/cli.py
  4. 84 9
      dulwich/objectspec.py
  5. 1 0
      tests/__init__.py
  6. 161 0
      tests/test_approxidate.py
  7. 0 111
      tests/test_cli.py
  8. 171 0
      tests/test_objectspec.py

+ 5 - 0
NEWS

@@ -27,6 +27,11 @@
  * Add support for ``dulwich replace`` command to create refs that replace objects.
    (Jelmer Vernooij, #1834)
 
+ * Implement advanced Git object specification support: index path lookup (``:``, ``:0:``,
+   ``:1:``, ``:2:``, ``:3:``) for accessing files from the index and merge stages, and
+   reflog time specifications (``@{time}``) using Git's approxidate format (e.g.,
+   ``HEAD@{yesterday}`, ``master@{2.weeks.ago}``). (Jelmer Vernooij, #1783)
+
 0.24.7	2025-10-23
 
  * Add sparse index support for improved performance with large repositories.

+ 161 - 0
dulwich/approxidate.py

@@ -0,0 +1,161 @@
+# approxidate.py -- Parsing of Git's "approxidate" time specifications
+# Copyright (C) 2025 Jelmer Vernooij <jelmer@jelmer.uk>
+#
+# SPDX-License-Identifier: Apache-2.0 OR GPL-2.0-or-later
+# Dulwich is dual-licensed under the Apache License, Version 2.0 and the GNU
+# General Public License as published by the Free Software Foundation; version 2.0
+# or (at your option) any later version. You can redistribute it and/or
+# modify it under the terms of either of these two licenses.
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# You should have received a copy of the licenses; if not, see
+# <http://www.gnu.org/licenses/> for a copy of the GNU General Public License
+# and <http://www.apache.org/licenses/LICENSE-2.0> for a copy of the Apache
+# License, Version 2.0.
+#
+
+"""Parsing of Git's "approxidate" time specifications.
+
+Git uses a flexible date parser called "approxidate" that accepts various
+formats for specifying dates and times, including:
+- Relative times: "yesterday", "2 days ago", "2.weeks.ago"
+- Absolute dates: "2005-04-07", "2005-04-07 22:13:13"
+- Unix timestamps: "1234567890"
+- Special keywords: "now", "today", "yesterday"
+"""
+
+import time
+from datetime import datetime
+from typing import Union
+
+
+def parse_approxidate(time_spec: Union[str, bytes]) -> int:
+    """Parse a Git approxidate specification and return a Unix timestamp.
+
+    Args:
+        time_spec: Time specification string. Can be:
+            - A Unix timestamp (integer as string)
+            - A relative time like "2 weeks ago" or "2.weeks.ago"
+            - Special keywords: "now", "today", "yesterday"
+            - Absolute date: "2005-04-07" or "2005-04-07 22:13:13"
+
+    Returns:
+        Unix timestamp (seconds since epoch)
+
+    Raises:
+        ValueError: If the time specification cannot be parsed
+    """
+    if isinstance(time_spec, bytes):
+        time_spec = time_spec.decode("utf-8")
+
+    time_spec = time_spec.strip()
+
+    # Get current time
+    now = time.time()
+
+    # Handle special keywords
+    if time_spec == "yesterday":
+        return int(now - 86400)
+    elif time_spec == "today":
+        # Start of today (midnight)
+        dt = datetime.fromtimestamp(now)
+        dt = dt.replace(hour=0, minute=0, second=0, microsecond=0)
+        return int(dt.timestamp())
+    elif time_spec == "now":
+        return int(now)
+
+    # Try parsing as Unix timestamp
+    try:
+        return int(time_spec)
+    except ValueError:
+        pass
+
+    # Handle relative time specifications
+    # Supports both "2 weeks ago" and "2.weeks.ago" formats
+    if " ago" in time_spec or ".ago" in time_spec:
+        seconds_ago = parse_relative_time(time_spec)
+        return int(now - seconds_ago)
+
+    # Try parsing as absolute timestamp formats
+    # Git supports various formats like:
+    # - "2005-04-07" (ISO date)
+    # - "2005-04-07 22:13:13" (ISO datetime)
+    # - "2005-04-07T22:13:13" (ISO 8601)
+    formats = [
+        "%Y-%m-%d %H:%M:%S",
+        "%Y-%m-%dT%H:%M:%S",
+        "%Y-%m-%d",
+        "%Y/%m/%d %H:%M:%S",
+        "%Y/%m/%d",
+    ]
+
+    for fmt in formats:
+        try:
+            dt = datetime.strptime(time_spec, fmt)
+            return int(dt.timestamp())
+        except ValueError:
+            continue
+
+    raise ValueError(f"Unable to parse time specification: {time_spec!r}")
+
+
+def parse_relative_time(time_str: str) -> int:
+    """Parse a relative time string like '2 weeks ago' into seconds.
+
+    Args:
+        time_str: String like '2 weeks ago', '2.weeks.ago', or 'now'
+
+    Returns:
+        Number of seconds (relative to current time)
+
+    Raises:
+        ValueError: If the time string cannot be parsed
+    """
+    if time_str == "now":
+        return 0
+
+    # Normalize dot-separated format to space-separated
+    # "2.weeks.ago" -> "2 weeks ago"
+    normalized = time_str.replace(".ago", " ago").replace(".", " ")
+
+    if not normalized.endswith(" ago"):
+        raise ValueError(f"Invalid relative time format: {time_str}")
+
+    parts = normalized[:-4].split()
+    if len(parts) != 2:
+        raise ValueError(f"Invalid relative time format: {time_str}")
+
+    try:
+        num = int(parts[0])
+        unit = parts[1]
+
+        multipliers = {
+            "second": 1,
+            "seconds": 1,
+            "minute": 60,
+            "minutes": 60,
+            "hour": 3600,
+            "hours": 3600,
+            "day": 86400,
+            "days": 86400,
+            "week": 604800,
+            "weeks": 604800,
+            "month": 2592000,  # 30 days
+            "months": 2592000,
+            "year": 31536000,  # 365 days
+            "years": 31536000,
+        }
+
+        if unit in multipliers:
+            return num * multipliers[unit]
+        else:
+            raise ValueError(f"Unknown time unit: {unit}")
+    except ValueError as e:
+        if "invalid literal" in str(e):
+            raise ValueError(f"Invalid number in relative time: {parts[0]}")
+        raise

+ 9 - 63
dulwich/cli.py

@@ -311,59 +311,6 @@ def signal_quit(signal: int, frame: Optional[types.FrameType]) -> None:
     pdb.set_trace()
 
 
-def parse_relative_time(time_str: str) -> int:
-    """Parse a relative time string like '2 weeks ago' into seconds.
-
-    Args:
-        time_str: String like '2 weeks ago' or 'now'
-
-    Returns:
-        Number of seconds
-
-    Raises:
-        ValueError: If the time string cannot be parsed
-    """
-    if time_str == "now":
-        return 0
-
-    if not time_str.endswith(" ago"):
-        raise ValueError(f"Invalid relative time format: {time_str}")
-
-    parts = time_str[:-4].split()
-    if len(parts) != 2:
-        raise ValueError(f"Invalid relative time format: {time_str}")
-
-    try:
-        num = int(parts[0])
-        unit = parts[1]
-
-        multipliers = {
-            "second": 1,
-            "seconds": 1,
-            "minute": 60,
-            "minutes": 60,
-            "hour": 3600,
-            "hours": 3600,
-            "day": 86400,
-            "days": 86400,
-            "week": 604800,
-            "weeks": 604800,
-            "month": 2592000,  # 30 days
-            "months": 2592000,
-            "year": 31536000,  # 365 days
-            "years": 31536000,
-        }
-
-        if unit in multipliers:
-            return num * multipliers[unit]
-        else:
-            raise ValueError(f"Unknown time unit: {unit}")
-    except ValueError as e:
-        if "invalid literal" in str(e):
-            raise ValueError(f"Invalid number in relative time: {parts[0]}")
-        raise
-
-
 def parse_time_to_timestamp(time_spec: str) -> int:
     """Parse a time specification and return a Unix timestamp.
 
@@ -383,7 +330,9 @@ def parse_time_to_timestamp(time_spec: str) -> int:
     """
     import time
 
-    # Handle special cases
+    from .approxidate import parse_approxidate
+
+    # Handle special cases specific to CLI
     if time_spec == "all":
         # Expire all entries - set to future time so everything is "older"
         return int(time.time()) + (100 * 365 * 24 * 60 * 60)  # 100 years in future
@@ -391,15 +340,8 @@ def parse_time_to_timestamp(time_spec: str) -> int:
         # Never expire - set to epoch start so nothing is older
         return 0
 
-    # Try parsing as direct Unix timestamp
-    try:
-        return int(time_spec)
-    except ValueError:
-        pass
-
-    # Parse relative time and convert to timestamp
-    seconds_ago = parse_relative_time(time_spec)
-    return int(time.time()) - seconds_ago
+    # Use approxidate parser for everything else
+    return parse_approxidate(time_spec)
 
 
 def format_bytes(bytes: float) -> str:
@@ -3221,6 +3163,8 @@ class cmd_prune(Command):
         # Parse expire grace period
         grace_period = DEFAULT_TEMPFILE_GRACE_PERIOD
         if parsed_args.expire:
+            from .approxidate import parse_relative_time
+
             try:
                 grace_period = parse_relative_time(parsed_args.expire)
             except ValueError:
@@ -4455,6 +4399,8 @@ class cmd_gc(Command):
         # Parse prune grace period
         grace_period = None
         if parsed_args.prune:
+            from .approxidate import parse_relative_time
+
             try:
                 grace_period = parse_relative_time(parsed_args.prune)
             except ValueError:

+ 84 - 9
dulwich/objectspec.py

@@ -87,31 +87,106 @@ def parse_object(repo: "Repo", objectish: Union[bytes, str]) -> "ShaFile":
     """
     objectish = to_bytes(objectish)
 
-    # Handle :<path> - lookup path in tree
+    # Handle :<path> - lookup path in tree or index
     if b":" in objectish:
         rev, path = objectish.split(b":", 1)
         if not rev:
-            raise NotImplementedError("Index path lookup (:path) not yet supported")
+            # Index path lookup: :path or :N:path where N is stage 0-3
+            stage = 0
+            if path and path[0:1].isdigit() and len(path) > 2 and path[1:2] == b":":
+                stage = int(path[0:1])
+                if stage > 3:
+                    raise ValueError(f"Invalid stage number: {stage}. Must be 0-3.")
+                path = path[2:]
+
+            # Open the index and look up the path
+
+            try:
+                index = repo.open_index()
+            except AttributeError:
+                raise NotImplementedError(
+                    "Index path lookup requires a non-bare repository"
+                )
+
+            if path not in index:
+                raise KeyError(f"Path {path!r} not found in index")
+
+            entry = index[path]
+            # Handle ConflictedIndexEntry (merge stages)
+            from .index import ConflictedIndexEntry
+
+            if isinstance(entry, ConflictedIndexEntry):
+                if stage == 0:
+                    raise ValueError(
+                        f"Path {path!r} has unresolved conflicts. "
+                        "Use :1:path, :2:path, or :3:path to access specific stages."
+                    )
+                elif stage == 1:
+                    if entry.ancestor is None:
+                        raise KeyError(f"Path {path!r} has no ancestor (stage 1)")
+                    return repo[entry.ancestor.sha]
+                elif stage == 2:
+                    if entry.this is None:
+                        raise KeyError(f"Path {path!r} has no 'this' version (stage 2)")
+                    return repo[entry.this.sha]
+                elif stage == 3:
+                    if entry.other is None:
+                        raise KeyError(
+                            f"Path {path!r} has no 'other' version (stage 3)"
+                        )
+                    return repo[entry.other.sha]
+            else:
+                # Regular IndexEntry - only stage 0 is valid
+                if stage != 0:
+                    raise ValueError(
+                        f"Path {path!r} has no conflicts. Only :0:{path!r} or :{path!r} is valid."
+                    )
+                return repo[entry.sha]
+
+        # Regular tree lookup: rev:path
         tree = parse_tree(repo, rev)
         _mode, sha = tree.lookup_path(repo.object_store.__getitem__, path)
         return repo[sha]
 
-    # Handle @{N} - reflog lookup
+    # Handle @{N} or @{time} - reflog lookup
     if b"@{" in objectish:
         base, rest = objectish.split(b"@{", 1)
         if not rest.endswith(b"}"):
             raise ValueError("Invalid @{} syntax")
         spec = rest[:-1]
-        if not spec.isdigit():
-            raise NotImplementedError(f"Only @{{N}} supported, not @{{{spec!r}}}")
 
         ref = base if base else b"HEAD"
         entries = list(repo.read_reflog(ref))
         entries.reverse()  # Git uses reverse chronological order
-        index = int(spec)
-        if index >= len(entries):
-            raise ValueError(f"Reflog for {ref!r} has only {len(entries)} entries")
-        return repo[entries[index].new_sha]
+
+        if spec.isdigit():
+            # Check if it's a small index or a timestamp
+            # Git treats values < number of entries as indices, larger values as timestamps
+            num = int(spec)
+            if num < len(entries):
+                # Treat as numeric index: @{N}
+                return repo[entries[num].new_sha]
+            # Otherwise fall through to treat as timestamp
+
+        # Time specification: @{time} (includes large numeric values)
+        from .approxidate import parse_approxidate
+
+        target_time = parse_approxidate(spec)
+
+        # Find the most recent entry at or before the target time
+        for reflog_entry in entries:
+            if reflog_entry.timestamp <= target_time:
+                return repo[reflog_entry.new_sha]
+
+        # If no entry is old enough, raise an error
+        if entries:
+            oldest_time = entries[-1].timestamp
+            raise ValueError(
+                f"Reflog for {ref!r} has no entries at or before {spec!r}. "
+                f"Oldest entry is at timestamp {oldest_time}"
+            )
+        else:
+            raise ValueError(f"Reflog for {ref!r} is empty")
 
     # Handle ^{} - tag dereferencing
     if objectish.endswith(b"^{}"):

+ 1 - 0
tests/__init__.py

@@ -124,6 +124,7 @@ class BlackboxTestCase(TestCase):
 def self_test_suite() -> unittest.TestSuite:
     names = [
         "annotate",
+        "approxidate",
         "archive",
         "attrs",
         "bisect",

+ 161 - 0
tests/test_approxidate.py

@@ -0,0 +1,161 @@
+# test_approxidate.py -- tests for approxidate.py
+# Copyright (C) 2025 Jelmer Vernooij <jelmer@jelmer.uk>
+#
+# SPDX-License-Identifier: Apache-2.0 OR GPL-2.0-or-later
+# Dulwich is dual-licensed under the Apache License, Version 2.0 and the GNU
+# General Public License as published by the Free Software Foundation; version 2.0
+# or (at your option) any later version. You can redistribute it and/or
+# modify it under the terms of either of these two licenses.
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# You should have received a copy of the licenses; if not, see
+# <http://www.gnu.org/licenses/> for a copy of the GNU General Public License
+# and <http://www.apache.org/licenses/LICENSE-2.0> for a copy of the Apache
+# License, Version 2.0.
+#
+
+"""Tests for approxidate parsing."""
+
+import time
+
+from dulwich.approxidate import parse_approxidate, parse_relative_time
+
+from . import TestCase
+
+
+class ParseRelativeTimeTests(TestCase):
+    """Test parse_relative_time."""
+
+    def test_now(self) -> None:
+        self.assertEqual(0, parse_relative_time("now"))
+
+    def test_seconds_ago(self) -> None:
+        self.assertEqual(5, parse_relative_time("5 seconds ago"))
+        self.assertEqual(1, parse_relative_time("1 second ago"))
+
+    def test_minutes_ago(self) -> None:
+        self.assertEqual(5 * 60, parse_relative_time("5 minutes ago"))
+        self.assertEqual(1 * 60, parse_relative_time("1 minute ago"))
+
+    def test_hours_ago(self) -> None:
+        self.assertEqual(5 * 3600, parse_relative_time("5 hours ago"))
+        self.assertEqual(1 * 3600, parse_relative_time("1 hour ago"))
+
+    def test_days_ago(self) -> None:
+        self.assertEqual(5 * 86400, parse_relative_time("5 days ago"))
+        self.assertEqual(1 * 86400, parse_relative_time("1 day ago"))
+
+    def test_weeks_ago(self) -> None:
+        self.assertEqual(2 * 604800, parse_relative_time("2 weeks ago"))
+        self.assertEqual(1 * 604800, parse_relative_time("1 week ago"))
+
+    def test_months_ago(self) -> None:
+        self.assertEqual(2 * 2592000, parse_relative_time("2 months ago"))
+        self.assertEqual(1 * 2592000, parse_relative_time("1 month ago"))
+
+    def test_years_ago(self) -> None:
+        self.assertEqual(2 * 31536000, parse_relative_time("2 years ago"))
+        self.assertEqual(1 * 31536000, parse_relative_time("1 year ago"))
+
+    def test_dot_separated_format(self) -> None:
+        # Git supports both "2 weeks ago" and "2.weeks.ago"
+        self.assertEqual(2 * 604800, parse_relative_time("2.weeks.ago"))
+        self.assertEqual(5 * 86400, parse_relative_time("5.days.ago"))
+
+    def test_invalid_format(self) -> None:
+        self.assertRaises(ValueError, parse_relative_time, "not a time")
+        self.assertRaises(ValueError, parse_relative_time, "5 weeks")  # Missing "ago"
+
+    def test_invalid_unit(self) -> None:
+        self.assertRaises(ValueError, parse_relative_time, "5 fortnights ago")
+
+    def test_invalid_number(self) -> None:
+        self.assertRaises(ValueError, parse_relative_time, "abc weeks ago")
+
+
+class ParseApproxidateTests(TestCase):
+    """Test parse_approxidate."""
+
+    def test_now(self) -> None:
+        result = parse_approxidate("now")
+        # Should be close to current time
+        self.assertAlmostEqual(result, time.time(), delta=2)
+
+    def test_yesterday(self) -> None:
+        result = parse_approxidate("yesterday")
+        expected = time.time() - 86400
+        self.assertAlmostEqual(result, expected, delta=2)
+
+    def test_today(self) -> None:
+        result = parse_approxidate("today")
+        # Should be midnight of current day
+        from datetime import datetime
+
+        now = datetime.fromtimestamp(time.time())
+        expected_dt = now.replace(hour=0, minute=0, second=0, microsecond=0)
+        expected = int(expected_dt.timestamp())
+        self.assertEqual(result, expected)
+
+    def test_unix_timestamp(self) -> None:
+        self.assertEqual(1234567890, parse_approxidate("1234567890"))
+        self.assertEqual(0, parse_approxidate("0"))
+
+    def test_relative_times(self) -> None:
+        # Test relative time parsing
+        result = parse_approxidate("2 weeks ago")
+        expected = time.time() - (2 * 604800)
+        self.assertAlmostEqual(result, expected, delta=2)
+
+        result = parse_approxidate("5.days.ago")
+        expected = time.time() - (5 * 86400)
+        self.assertAlmostEqual(result, expected, delta=2)
+
+    def test_absolute_date_iso(self) -> None:
+        # Test ISO format date
+        result = parse_approxidate("2009-02-13")
+        # 2009-02-13 00:00:00 UTC
+        from datetime import datetime
+
+        expected = int(datetime(2009, 2, 13, 0, 0, 0).timestamp())
+        self.assertEqual(result, expected)
+
+    def test_absolute_datetime_iso(self) -> None:
+        # Test ISO format datetime
+        result = parse_approxidate("2009-02-13 23:31:30")
+        from datetime import datetime
+
+        expected = int(datetime(2009, 2, 13, 23, 31, 30).timestamp())
+        self.assertEqual(result, expected)
+
+    def test_absolute_datetime_iso8601(self) -> None:
+        # Test ISO 8601 format
+        result = parse_approxidate("2009-02-13T23:31:30")
+        from datetime import datetime
+
+        expected = int(datetime(2009, 2, 13, 23, 31, 30).timestamp())
+        self.assertEqual(result, expected)
+
+    def test_bytes_input(self) -> None:
+        # Test that bytes input works
+        result = parse_approxidate(b"1234567890")
+        self.assertEqual(1234567890, result)
+
+        result = parse_approxidate(b"yesterday")
+        expected = time.time() - 86400
+        self.assertAlmostEqual(result, expected, delta=2)
+
+    def test_whitespace_handling(self) -> None:
+        # Test that leading/trailing whitespace is handled
+        self.assertEqual(1234567890, parse_approxidate("  1234567890  "))
+        result = parse_approxidate("  yesterday  ")
+        expected = time.time() - 86400
+        self.assertAlmostEqual(result, expected, delta=2)
+
+    def test_invalid_spec(self) -> None:
+        self.assertRaises(ValueError, parse_approxidate, "not a valid time")
+        self.assertRaises(ValueError, parse_approxidate, "abc123")

+ 0 - 111
tests/test_cli.py

@@ -40,7 +40,6 @@ from dulwich.cli import (
     detect_terminal_width,
     format_bytes,
     launch_editor,
-    parse_relative_time,
     write_columns,
 )
 from dulwich.repo import Repo
@@ -146,30 +145,6 @@ class HelperFunctionsTest(TestCase):
         result = launch_editor(b"Test template content")
         self.assertEqual(b"Test template content", result)
 
-    def test_parse_relative_time(self):
-        """Test parsing relative time strings."""
-        from dulwich.cli import parse_relative_time
-
-        self.assertEqual(0, parse_relative_time("now"))
-        self.assertEqual(60, parse_relative_time("1 minute ago"))
-        self.assertEqual(120, parse_relative_time("2 minutes ago"))
-        self.assertEqual(3600, parse_relative_time("1 hour ago"))
-        self.assertEqual(7200, parse_relative_time("2 hours ago"))
-        self.assertEqual(86400, parse_relative_time("1 day ago"))
-        self.assertEqual(172800, parse_relative_time("2 days ago"))
-        self.assertEqual(604800, parse_relative_time("1 week ago"))
-        self.assertEqual(1209600, parse_relative_time("2 weeks ago"))
-        self.assertEqual(2592000, parse_relative_time("1 month ago"))
-        self.assertEqual(31536000, parse_relative_time("1 year ago"))
-
-        # Test invalid formats
-        with self.assertRaises(ValueError):
-            parse_relative_time("invalid")
-        with self.assertRaises(ValueError):
-            parse_relative_time("2 days")  # Missing "ago"
-        with self.assertRaises(ValueError):
-            parse_relative_time("two days ago")  # Not a number
-
     def test_parse_time_to_timestamp(self):
         """Test parsing time specifications to Unix timestamps."""
         import time
@@ -3064,92 +3039,6 @@ class FormatBytesTestCase(TestCase):
         self.assertEqual("1000.0 TB", format_bytes(1024 * 1024 * 1024 * 1024 * 1000))
 
 
-class ParseRelativeTimeTestCase(TestCase):
-    """Tests for parse_relative_time function."""
-
-    def test_now(self):
-        """Test parsing 'now'."""
-        self.assertEqual(0, parse_relative_time("now"))
-
-    def test_seconds(self):
-        """Test parsing seconds."""
-        self.assertEqual(1, parse_relative_time("1 second ago"))
-        self.assertEqual(5, parse_relative_time("5 seconds ago"))
-        self.assertEqual(30, parse_relative_time("30 seconds ago"))
-
-    def test_minutes(self):
-        """Test parsing minutes."""
-        self.assertEqual(60, parse_relative_time("1 minute ago"))
-        self.assertEqual(300, parse_relative_time("5 minutes ago"))
-        self.assertEqual(1800, parse_relative_time("30 minutes ago"))
-
-    def test_hours(self):
-        """Test parsing hours."""
-        self.assertEqual(3600, parse_relative_time("1 hour ago"))
-        self.assertEqual(7200, parse_relative_time("2 hours ago"))
-        self.assertEqual(86400, parse_relative_time("24 hours ago"))
-
-    def test_days(self):
-        """Test parsing days."""
-        self.assertEqual(86400, parse_relative_time("1 day ago"))
-        self.assertEqual(604800, parse_relative_time("7 days ago"))
-        self.assertEqual(2592000, parse_relative_time("30 days ago"))
-
-    def test_weeks(self):
-        """Test parsing weeks."""
-        self.assertEqual(604800, parse_relative_time("1 week ago"))
-        self.assertEqual(1209600, parse_relative_time("2 weeks ago"))
-        self.assertEqual(
-            36288000, parse_relative_time("60 weeks ago")
-        )  # 60 * 7 * 24 * 60 * 60
-
-    def test_invalid_format(self):
-        """Test invalid time formats."""
-        with self.assertRaises(ValueError) as cm:
-            parse_relative_time("invalid")
-        self.assertIn("Invalid relative time format", str(cm.exception))
-
-        with self.assertRaises(ValueError) as cm:
-            parse_relative_time("2 weeks")
-        self.assertIn("Invalid relative time format", str(cm.exception))
-
-        with self.assertRaises(ValueError) as cm:
-            parse_relative_time("ago")
-        self.assertIn("Invalid relative time format", str(cm.exception))
-
-        with self.assertRaises(ValueError) as cm:
-            parse_relative_time("two weeks ago")
-        self.assertIn("Invalid number in relative time", str(cm.exception))
-
-    def test_invalid_unit(self):
-        """Test invalid time units."""
-        with self.assertRaises(ValueError) as cm:
-            parse_relative_time("5 fortnights ago")
-        self.assertIn("Unknown time unit: fortnights", str(cm.exception))
-
-        with self.assertRaises(ValueError) as cm:
-            parse_relative_time("2 decades ago")
-        self.assertIn("Unknown time unit: decades", str(cm.exception))
-
-    def test_singular_plural(self):
-        """Test that both singular and plural forms work."""
-        self.assertEqual(
-            parse_relative_time("1 second ago"), parse_relative_time("1 seconds ago")
-        )
-        self.assertEqual(
-            parse_relative_time("1 minute ago"), parse_relative_time("1 minutes ago")
-        )
-        self.assertEqual(
-            parse_relative_time("1 hour ago"), parse_relative_time("1 hours ago")
-        )
-        self.assertEqual(
-            parse_relative_time("1 day ago"), parse_relative_time("1 days ago")
-        )
-        self.assertEqual(
-            parse_relative_time("1 week ago"), parse_relative_time("1 weeks ago")
-        )
-
-
 class GetPagerTest(TestCase):
     """Tests for get_pager function."""
 

+ 171 - 0
tests/test_objectspec.py

@@ -246,6 +246,177 @@ class ParseObjectTests(TestCase):
             # HEAD@{2} is the third/oldest (c1)
             self.assertEqual(c1, parse_object(r, b"HEAD@{2}"))
 
+    def test_reflog_time_lookup(self) -> None:
+        # Use a real repo for reflog testing with time specifications
+        import tempfile
+
+        from dulwich.repo import Repo
+
+        with tempfile.TemporaryDirectory() as tmpdir:
+            r = Repo.init_bare(tmpdir)
+            c1, c2, c3 = build_commit_graph(r.object_store, [[1], [2, 1], [3, 2]])
+
+            # Write reflog entries with specific timestamps
+            # 1234567890 = 2009-02-13 23:31:30 UTC
+            r._write_reflog(
+                b"HEAD",
+                None,
+                c1.id,
+                b"Test User <test@example.com>",
+                1234567890,
+                0,
+                b"commit: Initial commit",
+            )
+            # 1234657890 = 2009-02-14 23:31:30 UTC (1 day + 1 second later)
+            r._write_reflog(
+                b"HEAD",
+                c1.id,
+                c2.id,
+                b"Test User <test@example.com>",
+                1234657890,
+                0,
+                b"commit: Second commit",
+            )
+            # 1235000000 = 2009-02-18 19:33:20 UTC
+            r._write_reflog(
+                b"HEAD",
+                c2.id,
+                c3.id,
+                b"Test User <test@example.com>",
+                1235000000,
+                0,
+                b"commit: Third commit",
+            )
+
+            # Lookup by timestamp - should get the most recent entry at or before time
+            self.assertEqual(c1, parse_object(r, b"HEAD@{1234567890}"))
+            self.assertEqual(c2, parse_object(r, b"HEAD@{1234657890}"))
+            self.assertEqual(c3, parse_object(r, b"HEAD@{1235000000}"))
+            # Future timestamp should get latest entry
+            self.assertEqual(c3, parse_object(r, b"HEAD@{9999999999}"))
+
+    def test_index_path_lookup_stage0(self) -> None:
+        # Test index path lookup for stage 0 (normal files)
+        import tempfile
+
+        from dulwich.repo import Repo
+
+        with tempfile.TemporaryDirectory() as tmpdir:
+            r = Repo.init(tmpdir)
+
+            # Create a blob and add it to the index
+            b = Blob.from_string(b"Test content")
+            r.object_store.add_object(b)
+
+            # Add to index
+            index = r.open_index()
+            from dulwich.index import IndexEntry
+
+            index[b"test.txt"] = IndexEntry(
+                ctime=(0, 0),
+                mtime=(0, 0),
+                dev=0,
+                ino=0,
+                mode=0o100644,
+                uid=0,
+                gid=0,
+                size=len(b.data),
+                sha=b.id,
+            )
+            index.write()
+
+            # Test :path syntax (defaults to stage 0)
+            result = parse_object(r, b":test.txt")
+            self.assertEqual(b"Test content", result.data)
+
+            # Test :0:path syntax (explicit stage 0)
+            result = parse_object(r, b":0:test.txt")
+            self.assertEqual(b"Test content", result.data)
+
+    def test_index_path_lookup_conflicts(self) -> None:
+        # Test index path lookup with merge conflicts (stages 1-3)
+        import tempfile
+
+        from dulwich.index import ConflictedIndexEntry, IndexEntry
+        from dulwich.repo import Repo
+
+        with tempfile.TemporaryDirectory() as tmpdir:
+            r = Repo.init(tmpdir)
+
+            # Create three different versions of a file
+            b_ancestor = Blob.from_string(b"Ancestor content")
+            b_this = Blob.from_string(b"This content")
+            b_other = Blob.from_string(b"Other content")
+            r.object_store.add_object(b_ancestor)
+            r.object_store.add_object(b_this)
+            r.object_store.add_object(b_other)
+
+            # Add conflicted entry to index
+            index = r.open_index()
+            index[b"conflict.txt"] = ConflictedIndexEntry(
+                ancestor=IndexEntry(
+                    ctime=(0, 0),
+                    mtime=(0, 0),
+                    dev=0,
+                    ino=0,
+                    mode=0o100644,
+                    uid=0,
+                    gid=0,
+                    size=len(b_ancestor.data),
+                    sha=b_ancestor.id,
+                ),
+                this=IndexEntry(
+                    ctime=(0, 0),
+                    mtime=(0, 0),
+                    dev=0,
+                    ino=0,
+                    mode=0o100644,
+                    uid=0,
+                    gid=0,
+                    size=len(b_this.data),
+                    sha=b_this.id,
+                ),
+                other=IndexEntry(
+                    ctime=(0, 0),
+                    mtime=(0, 0),
+                    dev=0,
+                    ino=0,
+                    mode=0o100644,
+                    uid=0,
+                    gid=0,
+                    size=len(b_other.data),
+                    sha=b_other.id,
+                ),
+            )
+            index.write()
+
+            # Test stage 1 (ancestor)
+            result = parse_object(r, b":1:conflict.txt")
+            self.assertEqual(b"Ancestor content", result.data)
+
+            # Test stage 2 (this)
+            result = parse_object(r, b":2:conflict.txt")
+            self.assertEqual(b"This content", result.data)
+
+            # Test stage 3 (other)
+            result = parse_object(r, b":3:conflict.txt")
+            self.assertEqual(b"Other content", result.data)
+
+            # Test that :conflict.txt raises an error for conflicted files
+            self.assertRaises(ValueError, parse_object, r, b":conflict.txt")
+
+    def test_index_path_not_found(self) -> None:
+        # Test error when path not in index
+        import tempfile
+
+        from dulwich.repo import Repo
+
+        with tempfile.TemporaryDirectory() as tmpdir:
+            r = Repo.init(tmpdir)
+
+            # Try to lookup non-existent path
+            self.assertRaises(KeyError, parse_object, r, b":nonexistent.txt")
+
 
 class ParseCommitRangeTests(TestCase):
     """Test parse_commit_range."""