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

Add support for git show-ref

Fixes #1830
Jelmer Vernooij 3 месяцев назад
Родитель
Сommit
d17a986eb5
4 измененных файлов с 481 добавлено и 1 удалено
  1. 6 0
      NEWS
  2. 139 1
      dulwich/cli.py
  3. 102 0
      dulwich/porcelain.py
  4. 234 0
      tests/test_cli.py

+ 6 - 0
NEWS

@@ -1,5 +1,11 @@
 0.24.5	2025-10-15
 
+ * Add support for ``git show-ref`` command to list references in a local
+   repository. Supports filtering by branches/tags, pattern matching,
+   dereferencing tags, verification mode, and existence checking. Available
+   as ``porcelain.show_ref()`` and ``dulwich show-ref`` CLI command.
+   (Jelmer Vernooij, #1830)
+
  * Fix HTTP authentication to preserve credentials from URLs when storing
    remote configuration. URLs with embedded credentials (like
    ``https://token@github.com/user/repo.git``) now correctly save those

+ 139 - 1
dulwich/cli.py

@@ -60,7 +60,12 @@ from dulwich import porcelain
 from .bundle import Bundle, create_bundle_from_repo, read_bundle, write_bundle
 from .client import get_transport_and_path
 from .config import Config
-from .errors import ApplyDeltaError, GitProtocolError
+from .errors import (
+    ApplyDeltaError,
+    FileFormatException,
+    GitProtocolError,
+    NotGitRepository,
+)
 from .index import Index
 from .log_utils import _configure_logging_from_trace
 from .objects import Commit, sha_to_hex, valid_hexsha
@@ -1600,6 +1605,138 @@ class cmd_show(Command):
                 porcelain.show(repo, args.objectish or None, outstream=output_stream)
 
 
+class cmd_show_ref(Command):
+    """List references in a local repository."""
+
+    def run(self, args: Sequence[str]) -> Optional[int]:
+        """Execute the show-ref command.
+
+        Args:
+            args: Command line arguments
+        Returns:
+            Exit code (0 for success, 1 for error/no matches, 2 for missing ref with --exists)
+        """
+        parser = argparse.ArgumentParser()
+        parser.add_argument(
+            "--head",
+            action="store_true",
+            help="Show the HEAD reference",
+        )
+        parser.add_argument(
+            "--branches",
+            action="store_true",
+            help="Limit to local branches",
+        )
+        parser.add_argument(
+            "--tags",
+            action="store_true",
+            help="Limit to local tags",
+        )
+        parser.add_argument(
+            "-d",
+            "--dereference",
+            action="store_true",
+            help="Dereference tags into object IDs",
+        )
+        parser.add_argument(
+            "-s",
+            "--hash",
+            nargs="?",
+            const=40,
+            type=int,
+            metavar="n",
+            help="Only show the OID, not the reference name",
+        )
+        parser.add_argument(
+            "--abbrev",
+            nargs="?",
+            const=7,
+            type=int,
+            metavar="n",
+            help="Abbreviate the object name",
+        )
+        parser.add_argument(
+            "--verify",
+            action="store_true",
+            help="Enable stricter reference checking (exact path match)",
+        )
+        parser.add_argument(
+            "--exists",
+            action="store_true",
+            help="Check whether the given reference exists",
+        )
+        parser.add_argument(
+            "-q",
+            "--quiet",
+            action="store_true",
+            help="Do not print any results to stdout",
+        )
+        parser.add_argument(
+            "patterns",
+            nargs="*",
+            help="Show references matching patterns",
+        )
+        parsed_args = parser.parse_args(args)
+
+        # Handle --exists mode
+        if parsed_args.exists:
+            if not parsed_args.patterns or len(parsed_args.patterns) != 1:
+                logger.error("--exists requires exactly one reference argument")
+                return 1
+
+            try:
+                with Repo(".") as repo:
+                    repo_refs = repo.get_refs()
+                    pattern_bytes = os.fsencode(parsed_args.patterns[0])
+                    if pattern_bytes in repo_refs:
+                        return 0  # Reference exists
+                    else:
+                        return 2  # Reference missing
+            except (NotGitRepository, OSError, FileFormatException) as e:
+                logger.error(f"Error looking up reference: {e}")
+                return 1  # Error looking up reference
+
+        # Regular show-ref mode
+        try:
+            matched_refs = porcelain.show_ref(
+                ".",
+                patterns=parsed_args.patterns if parsed_args.patterns else None,
+                head=parsed_args.head,
+                branches=parsed_args.branches,
+                tags=parsed_args.tags,
+                dereference=parsed_args.dereference,
+                verify=parsed_args.verify,
+            )
+        except (NotGitRepository, OSError, FileFormatException) as e:
+            logger.error(f"Error: {e}")
+            return 1
+
+        # Return error if no matches found (unless quiet)
+        if not matched_refs:
+            if parsed_args.verify and not parsed_args.quiet:
+                logger.error("error: no matching refs found")
+            return 1
+
+        # Output results
+        if not parsed_args.quiet:
+            abbrev_len = parsed_args.abbrev if parsed_args.abbrev else 40
+            hash_only = parsed_args.hash is not None
+            if hash_only and parsed_args.hash:
+                abbrev_len = parsed_args.hash
+
+            for sha, ref in matched_refs:
+                sha_str = sha.decode()
+                if abbrev_len < 40:
+                    sha_str = sha_str[:abbrev_len]
+
+                if hash_only:
+                    logger.info(sha_str)
+                else:
+                    logger.info(f"{sha_str} {ref.decode()}")
+
+        return 0
+
+
 class cmd_diff_tree(Command):
     """Compare the content and mode of trees."""
 
@@ -4698,6 +4835,7 @@ commands = {
     "rm": cmd_rm,
     "mv": cmd_mv,
     "show": cmd_show,
+    "show-ref": cmd_show_ref,
     "stash": cmd_stash,
     "status": cmd_status,
     "shortlog": cmd_shortlog,

+ 102 - 0
dulwich/porcelain.py

@@ -191,6 +191,7 @@ from .refs import (
     Ref,
     SymrefLoop,
     _import_remote_refs,
+    filter_ref_prefix,
 )
 from .repo import BaseRepo, Repo, get_user_identity
 from .server import (
@@ -3895,6 +3896,107 @@ def for_each_ref(
     return ret
 
 
+def show_ref(
+    repo: Union[Repo, str] = ".",
+    patterns: Optional[list[Union[str, bytes]]] = None,
+    head: bool = False,
+    branches: bool = False,
+    tags: bool = False,
+    dereference: bool = False,
+    verify: bool = False,
+) -> list[tuple[bytes, bytes]]:
+    """List references in a local repository.
+
+    Args:
+      repo: Path to the repository
+      patterns: Optional list of patterns to filter refs (matched from the end)
+      head: Show the HEAD reference
+      branches: Limit to local branches (refs/heads/)
+      tags: Limit to local tags (refs/tags/)
+      dereference: Dereference tags into object IDs
+      verify: Enable stricter reference checking (exact path match)
+    Returns: List of tuples with (sha, ref_name) or (sha, ref_name^{}) for dereferenced tags
+    """
+    # Convert string patterns to bytes
+    byte_patterns: Optional[list[bytes]] = None
+    if patterns:
+        byte_patterns = [os.fsencode(p) if isinstance(p, str) else p for p in patterns]
+
+    with open_repo_closing(repo) as r:
+        refs = r.get_refs()
+
+        # Filter by branches/tags if specified
+        if branches or tags:
+            prefixes = []
+            if branches:
+                prefixes.append(LOCAL_BRANCH_PREFIX)
+            if tags:
+                prefixes.append(LOCAL_TAG_PREFIX)
+            filtered_refs = filter_ref_prefix(refs, prefixes)
+        else:
+            # By default, show tags, heads, and remote refs (but not HEAD)
+            filtered_refs = filter_ref_prefix(refs, [b"refs/"])
+
+        # Add HEAD if requested
+        if head and b"HEAD" in refs:
+            filtered_refs[b"HEAD"] = refs[b"HEAD"]
+
+        # Filter by patterns if specified
+        if byte_patterns:
+            matching_refs: dict[bytes, bytes] = {}
+            for ref, sha in filtered_refs.items():
+                for pattern in byte_patterns:
+                    if verify:
+                        # Verify mode requires exact match
+                        if ref == pattern:
+                            matching_refs[ref] = sha
+                            break
+                    else:
+                        # Pattern matching from the end of the full name
+                        # Only complete parts are matched
+                        # E.g., "master" matches "refs/heads/master" but not "refs/heads/mymaster"
+                        pattern_parts = pattern.split(b"/")
+                        ref_parts = ref.split(b"/")
+
+                        # Try to match from the end
+                        if len(pattern_parts) <= len(ref_parts):
+                            # Check if the end of ref matches the pattern
+                            matches = True
+                            for i in range(len(pattern_parts)):
+                                if ref_parts[-(len(pattern_parts) - i)] != pattern_parts[i]:
+                                    matches = False
+                                    break
+                            if matches:
+                                matching_refs[ref] = sha
+                                break
+            filtered_refs = matching_refs
+
+        # Sort by ref name
+        sorted_refs = sorted(filtered_refs.items(), key=lambda x: x[0])
+
+        # Build result list
+        result: list[tuple[bytes, bytes]] = []
+        for ref, sha in sorted_refs:
+            result.append((sha, ref))
+
+            # Dereference tags if requested
+            if dereference and ref.startswith(LOCAL_TAG_PREFIX):
+                try:
+                    obj = r.get_object(sha)
+                    # Peel tag objects to get the underlying commit/object
+                    from .objects import Tag
+                    while obj.type_name == b"tag":
+                        assert isinstance(obj, Tag)
+                        _obj_class, sha = obj.object
+                        obj = r.get_object(sha)
+                    result.append((sha, ref + b"^{}"))
+                except KeyError:
+                    # Object not found, skip dereferencing
+                    pass
+
+    return result
+
+
 def ls_remote(
     remote: Union[str, bytes],
     config: Optional[Config] = None,

+ 234 - 0
tests/test_cli.py

@@ -1444,6 +1444,240 @@ class ShowCommandTest(DulwichCliTestCase):
         self.assertIn("Test commit", stdout)
 
 
+class ShowRefCommandTest(DulwichCliTestCase):
+    """Tests for show-ref command."""
+
+    def test_show_ref_basic(self):
+        """Test basic show-ref functionality."""
+        # Create a commit to have a HEAD ref
+        test_file = os.path.join(self.repo_path, "test.txt")
+        with open(test_file, "w") as f:
+            f.write("test content")
+        self._run_cli("add", "test.txt")
+        self._run_cli("commit", "--message=Test commit")
+
+        # Create a branch
+        self._run_cli("branch", "test-branch")
+
+        # Get the exact SHAs
+        master_sha = self.repo.refs[b"refs/heads/master"].decode()
+        test_branch_sha = self.repo.refs[b"refs/heads/test-branch"].decode()
+
+        # Run show-ref
+        with self.assertLogs("dulwich.cli", level="INFO") as cm:
+            _result, _stdout, _stderr = self._run_cli("show-ref")
+            output = "\n".join([record.message for record in cm.records])
+
+        expected = (
+            f"{master_sha} refs/heads/master\n{test_branch_sha} refs/heads/test-branch"
+        )
+        self.assertEqual(output, expected)
+
+    def test_show_ref_with_head(self):
+        """Test show-ref with --head option."""
+        # Create a commit to have a HEAD ref
+        test_file = os.path.join(self.repo_path, "test.txt")
+        with open(test_file, "w") as f:
+            f.write("test content")
+        self._run_cli("add", "test.txt")
+        self._run_cli("commit", "--message=Test commit")
+
+        # Get the exact SHAs
+        head_sha = self.repo.refs[b"HEAD"].decode()
+        master_sha = self.repo.refs[b"refs/heads/master"].decode()
+
+        # Run show-ref with --head
+        with self.assertLogs("dulwich.cli", level="INFO") as cm:
+            _result, _stdout, _stderr = self._run_cli("show-ref", "--head")
+            output = "\n".join([record.message for record in cm.records])
+
+        expected = f"{head_sha} HEAD\n{master_sha} refs/heads/master"
+        self.assertEqual(output, expected)
+
+    def test_show_ref_with_pattern(self):
+        """Test show-ref with pattern matching."""
+        # Create commits and branches
+        test_file = os.path.join(self.repo_path, "test.txt")
+        with open(test_file, "w") as f:
+            f.write("test content")
+        self._run_cli("add", "test.txt")
+        self._run_cli("commit", "--message=Test commit")
+        self._run_cli("branch", "feature-1")
+        self._run_cli("branch", "feature-2")
+        self._run_cli("branch", "bugfix-1")
+
+        # Get the exact SHA for master
+        master_sha = self.repo.refs[b"refs/heads/master"].decode()
+
+        # Test pattern matching for "master"
+        with self.assertLogs("dulwich.cli", level="INFO") as cm:
+            _result, _stdout, _stderr = self._run_cli("show-ref", "master")
+            output = "\n".join([record.message for record in cm.records])
+
+        expected = f"{master_sha} refs/heads/master"
+        self.assertEqual(output, expected)
+
+    def test_show_ref_branches_only(self):
+        """Test show-ref with --branches option."""
+        # Create commits and a tag
+        test_file = os.path.join(self.repo_path, "test.txt")
+        with open(test_file, "w") as f:
+            f.write("test content")
+        self._run_cli("add", "test.txt")
+        self._run_cli("commit", "--message=Test commit")
+        self._run_cli("tag", "v1.0")
+
+        # Get the exact SHA for master
+        master_sha = self.repo.refs[b"refs/heads/master"].decode()
+
+        # Run show-ref with --branches
+        with self.assertLogs("dulwich.cli", level="INFO") as cm:
+            _result, _stdout, _stderr = self._run_cli("show-ref", "--branches")
+            output = "\n".join([record.message for record in cm.records])
+
+        expected = f"{master_sha} refs/heads/master"
+        self.assertEqual(output, expected)
+
+    def test_show_ref_tags_only(self):
+        """Test show-ref with --tags option."""
+        # Create commits and tags
+        test_file = os.path.join(self.repo_path, "test.txt")
+        with open(test_file, "w") as f:
+            f.write("test content")
+        self._run_cli("add", "test.txt")
+        self._run_cli("commit", "--message=Test commit")
+        self._run_cli("tag", "v1.0")
+        self._run_cli("tag", "v2.0")
+
+        # Get the exact SHAs for tags
+        v1_sha = self.repo.refs[b"refs/tags/v1.0"].decode()
+        v2_sha = self.repo.refs[b"refs/tags/v2.0"].decode()
+
+        # Run show-ref with --tags
+        with self.assertLogs("dulwich.cli", level="INFO") as cm:
+            _result, _stdout, _stderr = self._run_cli("show-ref", "--tags")
+            output = "\n".join([record.message for record in cm.records])
+
+        expected = f"{v1_sha} refs/tags/v1.0\n{v2_sha} refs/tags/v2.0"
+        self.assertEqual(output, expected)
+
+    def test_show_ref_hash_only(self):
+        """Test show-ref with --hash option to show only OID."""
+        # Create a commit
+        test_file = os.path.join(self.repo_path, "test.txt")
+        with open(test_file, "w") as f:
+            f.write("test content")
+        self._run_cli("add", "test.txt")
+        self._run_cli("commit", "--message=Test commit")
+
+        # Get the exact SHA for master
+        master_sha = self.repo.refs[b"refs/heads/master"].decode()
+
+        # Run show-ref with --hash
+        with self.assertLogs("dulwich.cli", level="INFO") as cm:
+            _result, _stdout, _stderr = self._run_cli(
+                "show-ref", "--hash", "--", "master"
+            )
+            output = "\n".join([record.message for record in cm.records])
+
+        expected = f"{master_sha}"
+        self.assertEqual(output, expected)
+
+    def test_show_ref_verify(self):
+        """Test show-ref with --verify option for exact matching."""
+        # Create a commit
+        test_file = os.path.join(self.repo_path, "test.txt")
+        with open(test_file, "w") as f:
+            f.write("test content")
+        self._run_cli("add", "test.txt")
+        self._run_cli("commit", "--message=Test commit")
+
+        # Get the exact SHA for master
+        master_sha = self.repo.refs[b"refs/heads/master"].decode()
+
+        # Verify with exact ref path should succeed
+        with self.assertLogs("dulwich.cli", level="INFO") as cm:
+            result, _stdout, _stderr = self._run_cli(
+                "show-ref", "--verify", "refs/heads/master"
+            )
+            self.assertEqual(result, 0)
+            output = "\n".join([record.message for record in cm.records])
+
+        expected = f"{master_sha} refs/heads/master"
+        self.assertEqual(output, expected)
+
+        # Verify with partial name should fail
+        result, _stdout, _stderr = self._run_cli("show-ref", "--verify", "master")
+        self.assertEqual(result, 1)
+
+    def test_show_ref_exists(self):
+        """Test show-ref with --exists option."""
+        # Create a commit
+        test_file = os.path.join(self.repo_path, "test.txt")
+        with open(test_file, "w") as f:
+            f.write("test content")
+        self._run_cli("add", "test.txt")
+        self._run_cli("commit", "--message=Test commit")
+
+        # Check if existing ref exists
+        result, _stdout, _stderr = self._run_cli(
+            "show-ref", "--exists", "refs/heads/master"
+        )
+        self.assertEqual(result, 0)
+
+        # Check if non-existing ref exists
+        result, _stdout, _stderr = self._run_cli(
+            "show-ref", "--exists", "refs/heads/nonexistent"
+        )
+        self.assertEqual(result, 2)
+
+    def test_show_ref_quiet(self):
+        """Test show-ref with --quiet option."""
+        # Create a commit
+        test_file = os.path.join(self.repo_path, "test.txt")
+        with open(test_file, "w") as f:
+            f.write("test content")
+        self._run_cli("add", "test.txt")
+        self._run_cli("commit", "--message=Test commit")
+
+        # Run show-ref with --quiet - should not log anything
+        result, _stdout, _stderr = self._run_cli("show-ref", "--quiet")
+        self.assertEqual(result, 0)
+
+    def test_show_ref_abbrev(self):
+        """Test show-ref with --abbrev option."""
+        # Create a commit
+        test_file = os.path.join(self.repo_path, "test.txt")
+        with open(test_file, "w") as f:
+            f.write("test content")
+        self._run_cli("add", "test.txt")
+        self._run_cli("commit", "--message=Test commit")
+
+        # Get the exact SHA for master
+        master_sha = self.repo.refs[b"refs/heads/master"].decode()
+
+        # Run show-ref with --abbrev=7
+        with self.assertLogs("dulwich.cli", level="INFO") as cm:
+            _result, _stdout, _stderr = self._run_cli("show-ref", "--abbrev=7")
+            output = "\n".join([record.message for record in cm.records])
+
+        expected = f"{master_sha[:7]} refs/heads/master"
+        self.assertEqual(output, expected)
+
+    def test_show_ref_no_matches(self):
+        """Test show-ref returns error when no matches found."""
+        # Create a commit
+        test_file = os.path.join(self.repo_path, "test.txt")
+        with open(test_file, "w") as f:
+            f.write("test content")
+        self._run_cli("add", "test.txt")
+        self._run_cli("commit", "--message=Test commit")
+
+        # Search for non-existent pattern
+        result, _stdout, _stderr = self._run_cli("show-ref", "nonexistent")
+        self.assertEqual(result, 1)
+
+
 class FormatPatchCommandTest(DulwichCliTestCase):
     """Tests for format-patch command."""