Ver código fonte

Add basic reflog command (#1675)

Jelmer Vernooij 1 mês atrás
pai
commit
de8943b343
7 arquivos alterados com 398 adições e 32 exclusões
  1. 31 29
      NEWS
  2. 31 0
      dulwich/cli.py
  3. 31 0
      dulwich/porcelain.py
  4. 26 1
      dulwich/reflog.py
  5. 18 0
      dulwich/repo.py
  6. 138 1
      tests/test_porcelain.py
  7. 123 1
      tests/test_reflog.py

+ 31 - 29
NEWS

@@ -1,5 +1,7 @@
 0.23.3	UNRELEASED
 
+ * Add ``reflog`` command in porcelain. (Jelmer Vernooij)
+
 0.23.2	2025-07-07
 
  * Print deprecations on usage, not import.
@@ -19,7 +21,7 @@
    functionality works as expected.
    (Jelmer Vernooij, #780)
 
- * Add porcelain submodule commands: ``submodule_update``, ``submodule_add`` 
+ * Add porcelain submodule commands: ``submodule_update``, ``submodule_add``g
    CLI command, and ``submodule_update`` CLI command. Add ``--recurse-submodules``
    option to ``clone`` command. (#506, Jelmer Vernooij)
 
@@ -559,7 +561,7 @@
  * Build 32 bit wheels for Windows.
    (Benjamin Parzella)
 
- * tests: Ignore errors when deleting GNUPG 
+ * tests: Ignore errors when deleting GNUPGg
    home directory. Fixes spurious errors racing
    gnupg-agent. Thanks, Matěj Cepl. Fixes #1000
 
@@ -2150,7 +2152,7 @@ FEATURES
 
  IMPROVEMENTS
 
-  * Support passing a single revision to BaseRepo.get_walker() rather than a list of revisions. 
+  * Support passing a single revision to BaseRepo.get_walker() rather than a list of revisions.g
     (Alberto Ruiz)
 
   * Add `Repo.get_description` method. (Jelmer Vernooij)
@@ -2678,7 +2680,7 @@ FEATURES
 
   * Tweak server handler injection. (Dave Borowitz)
 
-  * PackIndex1 and PackIndex2 now subclass FilePackIndex, which is 
+  * PackIndex1 and PackIndex2 now subclass FilePackIndex, which isg
     itself a subclass of PackIndex. (Jelmer Vernooij)
 
  DOCUMENTATION
@@ -2713,7 +2715,7 @@ FEATURES
   * The GitClient interface has been cleaned up and instances are now reusable.
     (Augie Fackler)
 
-  * Allow overriding paths to executables in GitSSHClient. 
+  * Allow overriding paths to executables in GitSSHClient.g
     (Ross Light, Jelmer Vernooij, #585204)
 
   * Add PackBasedObjectStore.pack_loose_objects(). (Jelmer Vernooij)
@@ -2758,11 +2760,11 @@ FEATURES
 note: This list is most likely incomplete for 0.6.0.
 
  BUG FIXES
- 
+g
   * Fix ReceivePackHandler to disallow removing refs without delete-refs.
     (Dave Borowitz)
 
-  * Deal with capabilities required by the client, even if they 
+  * Deal with capabilities required by the client, even if theyg
     can not be disabled in the server. (Dave Borowitz)
 
   * Fix trailing newlines in generated patch files.
@@ -2781,27 +2783,27 @@ note: This list is most likely incomplete for 0.6.0.
 
   * Fix fetch if no progress function is specified. (Augie Fackler)
 
-  * Allow double-staging of files that are deleted in the index. 
+  * Allow double-staging of files that are deleted in the index.g
     (Dave Borowitz)
 
   * Fix RefsContainer.add_if_new to support dangling symrefs.
     (Dave Borowitz)
 
-  * Non-existent index files in non-bare repositories are now treated as 
+  * Non-existent index files in non-bare repositories are now treated asg
     empty. (Dave Borowitz)
 
-  * Always update ShaFile.id when the contents of the object get changed. 
+  * Always update ShaFile.id when the contents of the object get changed.g
     (Jelmer Vernooij)
 
   * Various Python2.4-compatibility fixes. (Dave Borowitz)
 
   * Fix thin pack handling. (Dave Borowitz)
- 
+g
  FEATURES
 
   * Add include-tag capability to server. (Dave Borowitz)
 
-  * New dulwich.fastexport module that can generate fastexport 
+  * New dulwich.fastexport module that can generate fastexportg
     streams. (Jelmer Vernooij)
 
   * Implemented BaseRepo.__contains__. (Jelmer Vernooij)
@@ -2818,7 +2820,7 @@ note: This list is most likely incomplete for 0.6.0.
 
   * Add various tests for the use of non-bare repositories. (Dave Borowitz)
 
-  * Cope with diffstat not being available on all platforms. 
+  * Cope with diffstat not being available on all platforms.g
     (Tay Ray Chuan, Jelmer Vernooij)
 
   * Add make_object and make_commit convenience functions to test utils.
@@ -2826,7 +2828,7 @@ note: This list is most likely incomplete for 0.6.0.
 
  API BREAKAGES
 
-  * The 'committer' and 'message' arguments to Repo.do_commit() have 
+  * The 'committer' and 'message' arguments to Repo.do_commit() haveg
     been swapped. 'committer' is now optional. (Jelmer Vernooij)
 
   * Repo.get_blob, Repo.commit, Repo.tag and Repo.tree are now deprecated.
@@ -2837,8 +2839,8 @@ note: This list is most likely incomplete for 0.6.0.
 
  API CHANGES
 
-  * The primary serialization APIs in dulwich.objects now work 
-    with chunks of strings rather than with full-text strings. 
+  * The primary serialization APIs in dulwich.objects now workg
+    with chunks of strings rather than with full-text strings.g
     (Jelmer Vernooij)
 
 0.5.02010-03-03
@@ -2852,7 +2854,7 @@ note: This list is most likely incomplete for 0.6.0.
   * Rework server protocol to be smarter and interoperate with cgit client.
     (Dave Borowitz)
 
-  * Add a GitFile class that uses the same locking protocol for writes as 
+  * Add a GitFile class that uses the same locking protocol for writes asg
     cgit. (Dave Borowitz)
 
   * Cope with forward slashes correctly in the index on Windows.
@@ -2860,14 +2862,14 @@ note: This list is most likely incomplete for 0.6.0.
 
  FEATURES
 
-  * --pure option to setup.py to allow building/installing without the C 
+  * --pure option to setup.py to allow building/installing without the Cg
     extensions. (Hal Wine, Anatoly Techtonik, Jelmer Vernooij, #434326)
 
   * Implement Repo.get_config(). (Jelmer Vernooij, Augie Fackler)
 
   * HTTP dumb and smart server. (Dave Borowitz)
 
-  * Add abstract baseclass for Repo that does not require file system 
+  * Add abstract baseclass for Repo that does not require file systemg
     operations. (Dave Borowitz)
 
 0.4.1	2010-01-03
@@ -2891,7 +2893,7 @@ note: This list is most likely incomplete for 0.6.0.
 
  API CHANGES
 
-  * dulwich.object_store.tree_lookup_path will now return the mode and 
+  * dulwich.object_store.tree_lookup_path will now return the mode andg
     sha of the object found rather than the object itself.
 
  BUG FIXES
@@ -2913,7 +2915,7 @@ note: This list is most likely incomplete for 0.6.0.
   * Implement Tree.__len__()
 
  BUG FIXES
-  
+ g
   * Check for 'objects' and 'refs' directories
     when looking for a Git repository. (#380818)
 
@@ -2922,7 +2924,7 @@ note: This list is most likely incomplete for 0.6.0.
  BUG FIXES
 
   * Support the encoding field in Commits.
-  
+ g
   * Some Windows compatibility fixes.
 
   * Fixed several issues in commit support.
@@ -2935,31 +2937,31 @@ note: This list is most likely incomplete for 0.6.0.
 
  FEATURES
 
-  * Implemented Repo.__getitem__, Repo.__setitem__ and Repo.__delitem__ to 
+  * Implemented Repo.__getitem__, Repo.__setitem__ and Repo.__delitem__ tog
     access content.
 
  API CHANGES
 
-  * Removed Repo.set_ref, Repo.remove_ref, Repo.tags, Repo.get_refs and 
+  * Removed Repo.set_ref, Repo.remove_ref, Repo.tags, Repo.get_refs andg
     Repo.heads in favor of Repo.refs, a dictionary-like object for accessing
     refs.
 
  BUG FIXES
 
-  * Removed import of 'sha' module in objects.py, which was causing 
+  * Removed import of 'sha' module in objects.py, which was causingg
     deprecation warnings on Python 2.6.
 
 0.3.0	2009-05-10
 
  FEATURES
 
-  * A new function 'commit_tree' has been added that can commit a tree 
+  * A new function 'commit_tree' has been added that can commit a treeg
     based on an index.
 
  BUG FIXES
 
   * The memory usage when generating indexes has been significantly reduced.
- 
+g
   * A memory leak in the C implementation of parse_tree has been fixed.
 
   * The send-pack smart server command now works. (Thanks Scott Chacon)
@@ -2980,7 +2982,7 @@ note: This list is most likely incomplete for 0.6.0.
 
   * Support for activity reporting in smart protocol client.
 
-  * Optional C extensions for better performance in a couple of 
+  * Optional C extensions for better performance in a couple ofg
     places that are performance-critical.
 
 0.1.1	2009-03-13
@@ -2989,7 +2991,7 @@ note: This list is most likely incomplete for 0.6.0.
 
   * Fixed regression in Repo.find_missing_objects()
 
-  * Don't fetch ^{} objects from remote hosts, as requesting them 
+  * Don't fetch ^{} objects from remote hosts, as requesting themg
     causes a hangup.
 
   * Always write pack to disk completely before calculating checksum.

+ 31 - 0
dulwich/cli.py

@@ -533,6 +533,36 @@ class cmd_repack(Command):
         porcelain.repack(".")
 
 
+class cmd_reflog(Command):
+    def run(self, args) -> None:
+        parser = argparse.ArgumentParser()
+        parser.add_argument(
+            "ref", nargs="?", default="HEAD", help="Reference to show reflog for"
+        )
+        parser.add_argument(
+            "--all", action="store_true", help="Show reflogs for all refs"
+        )
+        args = parser.parse_args(args)
+
+        if args.all:
+            # Show reflogs for all refs
+            for ref_bytes, entry in porcelain.reflog(".", all=True):
+                ref_str = ref_bytes.decode("utf-8", "replace")
+                short_new = entry.new_sha[:8].decode("ascii")
+                print(
+                    f"{short_new} {ref_str}: {entry.message.decode('utf-8', 'replace')}"
+                )
+        else:
+            ref = args.ref.encode("utf-8") if isinstance(args.ref, str) else args.ref
+
+            for i, entry in enumerate(porcelain.reflog(".", ref)):
+                # Format similar to git reflog
+                short_new = entry.new_sha[:8].decode("ascii")
+                print(
+                    f"{short_new} {ref.decode('utf-8', 'replace')}@{{{i}}}: {entry.message.decode('utf-8', 'replace')}"
+                )
+
+
 class cmd_reset(Command):
     def run(self, args) -> None:
         parser = argparse.ArgumentParser()
@@ -1968,6 +1998,7 @@ commands = {
     "push": cmd_push,
     "rebase": cmd_rebase,
     "receive-pack": cmd_receive_pack,
+    "reflog": cmd_reflog,
     "remote": cmd_remote,
     "repack": cmd_repack,
     "reset": cmd_reset,

+ 31 - 0
dulwich/porcelain.py

@@ -4544,3 +4544,34 @@ def bisect_replay(repo, log_file):
             log_content = log_file.read()
 
         state.replay(log_content)
+
+
+def reflog(repo=".", ref=b"HEAD", all=False):
+    """Show reflog entries for a reference or all references.
+
+    Args:
+        repo: Path to repository or a Repo object
+        ref: Reference name (defaults to HEAD)
+        all: If True, show reflogs for all refs (ignores ref parameter)
+
+    Yields:
+        If all=False: ReflogEntry objects
+        If all=True: Tuples of (ref_name, ReflogEntry) for all refs with reflogs
+    """
+    import os
+
+    from .reflog import iter_reflogs
+
+    if isinstance(ref, str):
+        ref = ref.encode("utf-8")
+
+    with open_repo_closing(repo) as r:
+        if not all:
+            yield from r.read_reflog(ref)
+        else:
+            logs_dir = os.path.join(r.controldir(), "logs")
+            # Use iter_reflogs to discover all reflogs
+            for ref_bytes in iter_reflogs(logs_dir):
+                # Read the reflog entries for this ref
+                for entry in r.read_reflog(ref_bytes):
+                    yield (ref_bytes, entry)

+ 26 - 1
dulwich/reflog.py

@@ -97,7 +97,7 @@ def read_reflog(f: BinaryIO) -> Generator[Entry, None, None]:
     Returns: Iterator over Entry objects
     """
     for line in f:
-        yield parse_reflog_line(line)
+        yield parse_reflog_line(line.rstrip(b"\n"))
 
 
 def drop_reflog_entry(f: BinaryIO, index: int, rewrite: bool = False) -> None:
@@ -157,3 +157,28 @@ def drop_reflog_entry(f: BinaryIO, index: int, rewrite: bool = False) -> None:
             )
         )
     f.truncate()
+
+
+def iter_reflogs(logs_dir: str) -> Generator[bytes, None, None]:
+    """Iterate over all reflogs in a repository.
+
+    Args:
+        logs_dir: Path to the logs directory (e.g., .git/logs)
+
+    Yields:
+        Reference names (as bytes) that have reflogs
+    """
+    import os
+    from pathlib import Path
+
+    if not os.path.exists(logs_dir):
+        return
+
+    logs_path = Path(logs_dir)
+    for log_file in logs_path.rglob("*"):
+        if log_file.is_file():
+            # Get the ref name by removing the logs_dir prefix
+            ref_name = str(log_file.relative_to(logs_path))
+            # Convert path separators to / for refs
+            ref_name = ref_name.replace(os.sep, "/")
+            yield ref_name.encode("utf-8")

+ 18 - 0
dulwich/repo.py

@@ -1338,6 +1338,24 @@ class Repo(BaseRepo):
                 + b"\n"
             )
 
+    def read_reflog(self, ref):
+        """Read reflog entries for a reference.
+
+        Args:
+          ref: Reference name (e.g. b'HEAD', b'refs/heads/master')
+
+        Yields:
+          reflog.Entry objects in chronological order (oldest first)
+        """
+        from .reflog import read_reflog
+
+        path = os.path.join(self.controldir(), "logs", os.fsdecode(ref))
+        try:
+            with open(path, "rb") as f:
+                yield from read_reflog(f)
+        except FileNotFoundError:
+            return
+
     @classmethod
     def discover(cls, start="."):
         """Iterate parent directories to discover a repository.

+ 138 - 1
tests/test_porcelain.py

@@ -7419,4 +7419,141 @@ class BisectTests(PorcelainTestCase):
         # Skip c2 if it's selected
         next_sha = porcelain.bisect_skip(self.repo_path, [c2.id])
         self.assertIsNotNone(next_sha)
-        self.assertNotEqual(next_sha, c2.id)
+
+
+class ReflogTest(PorcelainTestCase):
+    def test_reflog_head(self):
+        """Test reading HEAD reflog."""
+        # Create a commit
+        blob = Blob.from_string(b"test content")
+        self.repo.object_store.add_object(blob)
+
+        tree = Tree()
+        tree.add(b"test", 0o100644, blob.id)
+        self.repo.object_store.add_object(tree)
+
+        commit = Commit()
+        commit.tree = tree.id
+        commit.author = b"Test Author <test@example.com>"
+        commit.committer = b"Test Author <test@example.com>"
+        commit.commit_time = 1234567890
+        commit.commit_timezone = 0
+        commit.author_time = 1234567890
+        commit.author_timezone = 0
+        commit.message = b"Initial commit"
+        self.repo.object_store.add_object(commit)
+
+        # Write a reflog entry
+        self.repo._write_reflog(
+            b"HEAD",
+            ZERO_SHA,
+            commit.id,
+            b"Test Author <test@example.com>",
+            1234567890,
+            0,
+            b"commit (initial): Initial commit",
+        )
+
+        # Read reflog using porcelain
+        entries = list(porcelain.reflog(self.repo_path))
+        self.assertEqual(1, len(entries))
+        self.assertEqual(ZERO_SHA, entries[0].old_sha)
+        self.assertEqual(commit.id, entries[0].new_sha)
+        self.assertEqual(b"Test Author <test@example.com>", entries[0].committer)
+        self.assertEqual(1234567890, entries[0].timestamp)
+        self.assertEqual(0, entries[0].timezone)
+        self.assertEqual(b"commit (initial): Initial commit", entries[0].message)
+
+    def test_reflog_with_string_ref(self):
+        """Test reading reflog with string reference."""
+        # Create a commit
+        blob = Blob.from_string(b"test content")
+        self.repo.object_store.add_object(blob)
+
+        tree = Tree()
+        tree.add(b"test", 0o100644, blob.id)
+        self.repo.object_store.add_object(tree)
+
+        commit = Commit()
+        commit.tree = tree.id
+        commit.author = b"Test Author <test@example.com>"
+        commit.committer = b"Test Author <test@example.com>"
+        commit.commit_time = 1234567890
+        commit.commit_timezone = 0
+        commit.author_time = 1234567890
+        commit.author_timezone = 0
+        commit.message = b"Initial commit"
+        self.repo.object_store.add_object(commit)
+
+        # Write a reflog entry
+        self.repo._write_reflog(
+            b"refs/heads/master",
+            ZERO_SHA,
+            commit.id,
+            b"Test Author <test@example.com>",
+            1234567890,
+            0,
+            b"commit (initial): Initial commit",
+        )
+
+        # Read reflog using porcelain with string ref
+        entries = list(porcelain.reflog(self.repo_path, "refs/heads/master"))
+        self.assertEqual(1, len(entries))
+        self.assertEqual(commit.id, entries[0].new_sha)
+
+    def test_reflog_all(self):
+        """Test reading all reflogs."""
+        # Create a commit
+        blob = Blob.from_string(b"test content")
+        self.repo.object_store.add_object(blob)
+
+        tree = Tree()
+        tree.add(b"test", 0o100644, blob.id)
+        self.repo.object_store.add_object(tree)
+
+        commit = Commit()
+        commit.tree = tree.id
+        commit.author = b"Test Author <test@example.com>"
+        commit.committer = b"Test Author <test@example.com>"
+        commit.commit_time = 1234567890
+        commit.commit_timezone = 0
+        commit.author_time = 1234567890
+        commit.author_timezone = 0
+        commit.message = b"Initial commit"
+        self.repo.object_store.add_object(commit)
+
+        # Write reflog entries for HEAD and master
+        self.repo._write_reflog(
+            b"HEAD",
+            ZERO_SHA,
+            commit.id,
+            b"Test Author <test@example.com>",
+            1234567890,
+            0,
+            b"commit (initial): Initial commit",
+        )
+        self.repo._write_reflog(
+            b"refs/heads/master",
+            ZERO_SHA,
+            commit.id,
+            b"Test Author <test@example.com>",
+            1234567891,
+            0,
+            b"branch: Created from HEAD",
+        )
+
+        # Read all reflogs using porcelain
+        entries = list(porcelain.reflog(self.repo_path, all=True))
+
+        # Should have at least 2 entries (HEAD and refs/heads/master)
+        self.assertGreaterEqual(len(entries), 2)
+
+        # Check that we got entries from different refs
+        refs_seen = set()
+        for ref, entry in entries:
+            refs_seen.add(ref)
+            self.assertEqual(commit.id, entry.new_sha)
+
+        # Should have seen at least HEAD and refs/heads/master
+        self.assertIn(b"HEAD", refs_seen)
+        self.assertIn(b"refs/heads/master", refs_seen)

+ 123 - 1
tests/test_reflog.py

@@ -21,15 +21,18 @@
 
 """Tests for dulwich.reflog."""
 
+import tempfile
 from io import BytesIO
 
-from dulwich.objects import ZERO_SHA
+from dulwich.objects import ZERO_SHA, Blob, Commit, Tree
 from dulwich.reflog import (
     drop_reflog_entry,
     format_reflog_line,
+    iter_reflogs,
     parse_reflog_line,
     read_reflog,
 )
+from dulwich.repo import Repo
 
 from . import TestCase
 
@@ -142,3 +145,122 @@ class ReflogDropTests(TestCase):
         self.assertEqual(len(log), 1)
         self.assertEqual(ZERO_SHA, log[0].old_sha)
         self.assertEqual(self.original_log[2].new_sha, log[0].new_sha)
+
+
+class RepoReflogTests(TestCase):
+    def setUp(self) -> None:
+        TestCase.setUp(self)
+        self.test_dir = tempfile.mkdtemp()
+        self.repo = Repo.init(self.test_dir)
+
+    def tearDown(self) -> None:
+        TestCase.tearDown(self)
+        import shutil
+
+        shutil.rmtree(self.test_dir)
+
+    def test_read_reflog_nonexistent(self) -> None:
+        # Reading a reflog that doesn't exist should return empty
+        entries = list(self.repo.read_reflog(b"refs/heads/nonexistent"))
+        self.assertEqual([], entries)
+
+    def test_read_reflog_head(self) -> None:
+        # Create a commit to generate a reflog entry
+        blob = Blob.from_string(b"test content")
+        self.repo.object_store.add_object(blob)
+
+        tree = Tree()
+        tree.add(b"test", 0o100644, blob.id)
+        self.repo.object_store.add_object(tree)
+
+        commit = Commit()
+        commit.tree = tree.id
+        commit.author = b"Test Author <test@example.com>"
+        commit.committer = b"Test Author <test@example.com>"
+        commit.commit_time = 1234567890
+        commit.commit_timezone = 0
+        commit.author_time = 1234567890
+        commit.author_timezone = 0
+        commit.message = b"Initial commit"
+        self.repo.object_store.add_object(commit)
+
+        # Manually write a reflog entry
+        self.repo._write_reflog(
+            b"HEAD",
+            ZERO_SHA,
+            commit.id,
+            b"Test Author <test@example.com>",
+            1234567890,
+            0,
+            b"commit (initial): Initial commit",
+        )
+
+        # Read the reflog
+        entries = list(self.repo.read_reflog(b"HEAD"))
+        self.assertEqual(1, len(entries))
+        self.assertEqual(ZERO_SHA, entries[0].old_sha)
+        self.assertEqual(commit.id, entries[0].new_sha)
+        self.assertEqual(b"Test Author <test@example.com>", entries[0].committer)
+        self.assertEqual(1234567890, entries[0].timestamp)
+        self.assertEqual(0, entries[0].timezone)
+        self.assertEqual(b"commit (initial): Initial commit", entries[0].message)
+
+    def test_iter_reflogs(self) -> None:
+        # Create commits and reflog entries
+        blob = Blob.from_string(b"test content")
+        self.repo.object_store.add_object(blob)
+
+        tree = Tree()
+        tree.add(b"test", 0o100644, blob.id)
+        self.repo.object_store.add_object(tree)
+
+        commit = Commit()
+        commit.tree = tree.id
+        commit.author = b"Test Author <test@example.com>"
+        commit.committer = b"Test Author <test@example.com>"
+        commit.commit_time = 1234567890
+        commit.commit_timezone = 0
+        commit.author_time = 1234567890
+        commit.author_timezone = 0
+        commit.message = b"Initial commit"
+        self.repo.object_store.add_object(commit)
+
+        # Write reflog entries for multiple refs
+        self.repo._write_reflog(
+            b"HEAD",
+            ZERO_SHA,
+            commit.id,
+            b"Test Author <test@example.com>",
+            1234567890,
+            0,
+            b"commit (initial): Initial commit",
+        )
+        self.repo._write_reflog(
+            b"refs/heads/master",
+            ZERO_SHA,
+            commit.id,
+            b"Test Author <test@example.com>",
+            1234567891,
+            0,
+            b"branch: Created from HEAD",
+        )
+        self.repo._write_reflog(
+            b"refs/heads/develop",
+            ZERO_SHA,
+            commit.id,
+            b"Test Author <test@example.com>",
+            1234567892,
+            0,
+            b"branch: Created from HEAD",
+        )
+
+        # Use iter_reflogs to get all reflogs
+        import os
+
+        logs_dir = os.path.join(self.repo.controldir(), "logs")
+        reflogs = list(iter_reflogs(logs_dir))
+
+        # Should have at least HEAD, refs/heads/master, and refs/heads/develop
+        self.assertIn(b"HEAD", reflogs)
+        self.assertIn(b"refs/heads/master", reflogs)
+        self.assertIn(b"refs/heads/develop", reflogs)