Jelmer Vernooij 3 месяцев назад
Родитель
Сommit
ee003b95bc
6 измененных файлов с 1069 добавлено и 1 удалено
  1. 13 0
      NEWS
  2. 236 1
      dulwich/cli.py
  3. 384 0
      dulwich/porcelain.py
  4. 28 0
      dulwich/refs.py
  5. 367 0
      tests/test_cli.py
  6. 41 0
      tests/test_refs.py

+ 13 - 0
NEWS

@@ -1,5 +1,18 @@
+0.24.6	2025-10-17
+
+ * Add support for ``git show-branch`` command to display branches and their
+   commits. Supports filtering by local/remote branches, topological ordering,
+   list mode, independent branch detection, and merge base calculation.
+   (Jelmer Vernooij, #1829)
+
 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

+ 236 - 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,234 @@ 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_show_branch(Command):
+    """Show branches and their commits."""
+
+    def run(self, args: Sequence[str]) -> Optional[int]:
+        """Execute the show-branch command.
+
+        Args:
+            args: Command line arguments
+        Returns:
+            Exit code (0 for success, 1 for error)
+        """
+        parser = argparse.ArgumentParser()
+        parser.add_argument(
+            "-r",
+            "--remotes",
+            action="store_true",
+            help="Show remote-tracking branches",
+        )
+        parser.add_argument(
+            "-a",
+            "--all",
+            dest="all_branches",
+            action="store_true",
+            help="Show both remote-tracking and local branches",
+        )
+        parser.add_argument(
+            "--current",
+            action="store_true",
+            help="Include current branch if not given on command line",
+        )
+        parser.add_argument(
+            "--topo-order",
+            dest="topo_order",
+            action="store_true",
+            help="Show commits in topological order",
+        )
+        parser.add_argument(
+            "--date-order",
+            action="store_true",
+            help="Show commits in date order (default)",
+        )
+        parser.add_argument(
+            "--more",
+            type=int,
+            metavar="n",
+            help="Show n more commits beyond common ancestor",
+        )
+        parser.add_argument(
+            "--list",
+            dest="list_branches",
+            action="store_true",
+            help="Show only branch names and their tip commits",
+        )
+        parser.add_argument(
+            "--independent",
+            dest="independent_branches",
+            action="store_true",
+            help="Show only branches not reachable from any other",
+        )
+        parser.add_argument(
+            "--merge-base",
+            dest="merge_base",
+            action="store_true",
+            help="Show merge base of specified branches",
+        )
+        parser.add_argument(
+            "branches",
+            nargs="*",
+            help="Branches to show (default: all local branches)",
+        )
+        parsed_args = parser.parse_args(args)
+
+        try:
+            output_lines = porcelain.show_branch(
+                ".",
+                branches=parsed_args.branches if parsed_args.branches else None,
+                all_branches=parsed_args.all_branches,
+                remotes=parsed_args.remotes,
+                current=parsed_args.current,
+                topo_order=parsed_args.topo_order,
+                more=parsed_args.more,
+                list_branches=parsed_args.list_branches,
+                independent_branches=parsed_args.independent_branches,
+                merge_base=parsed_args.merge_base,
+            )
+        except (NotGitRepository, OSError, FileFormatException) as e:
+            logger.error(f"Error: {e}")
+            return 1
+
+        # Output results
+        for line in output_lines:
+            logger.info(line)
+
+        return 0
+
+
 class cmd_diff_tree(Command):
     """Compare the content and mode of trees."""
 
@@ -4698,6 +4931,8 @@ commands = {
     "rm": cmd_rm,
     "mv": cmd_mv,
     "show": cmd_show,
+    "show-branch": cmd_show_branch,
+    "show-ref": cmd_show_ref,
     "stash": cmd_stash,
     "status": cmd_status,
     "shortlog": cmd_shortlog,

+ 384 - 0
dulwich/porcelain.py

@@ -191,6 +191,8 @@ from .refs import (
     Ref,
     SymrefLoop,
     _import_remote_refs,
+    filter_ref_prefix,
+    shorten_ref_name,
 )
 from .repo import BaseRepo, Repo, get_user_identity
 from .server import (
@@ -3895,6 +3897,388 @@ 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 show_branch(
+    repo: Union[Repo, str] = ".",
+    branches: Optional[list[Union[str, bytes]]] = None,
+    all_branches: bool = False,
+    remotes: bool = False,
+    current: bool = False,
+    topo_order: bool = False,
+    more: Optional[int] = None,
+    list_branches: bool = False,
+    independent_branches: bool = False,
+    merge_base: bool = False,
+) -> list[str]:
+    """Display branches and their commits.
+
+    Args:
+      repo: Path to the repository
+      branches: List of specific branches to show (default: all local branches)
+      all_branches: Show both local and remote branches
+      remotes: Show only remote branches
+      current: Include current branch if not specified
+      topo_order: Show in topological order instead of chronological
+      more: Show N more commits beyond common ancestor (negative to show only headers)
+      list_branches: Synonym for more=-1 (show only branch headers)
+      independent_branches: Show only branches not reachable from others
+      merge_base: Show merge bases instead of commit list
+
+    Returns:
+      List of output lines
+    """
+    from .graph import find_octopus_base, independent
+
+    output_lines: list[str] = []
+
+    with open_repo_closing(repo) as r:
+        refs = r.get_refs()
+
+        # Determine which branches to show
+        branch_refs: dict[bytes, bytes] = {}
+
+        if branches:
+            # Specific branches requested
+            for branch in branches:
+                branch_bytes = (
+                    os.fsencode(branch) if isinstance(branch, str) else branch
+                )
+                # Try as full ref name first
+                if branch_bytes in refs:
+                    branch_refs[branch_bytes] = refs[branch_bytes]
+                # Try as branch name
+                elif LOCAL_BRANCH_PREFIX + branch_bytes in refs:
+                    branch_refs[LOCAL_BRANCH_PREFIX + branch_bytes] = refs[
+                        LOCAL_BRANCH_PREFIX + branch_bytes
+                    ]
+                # Try as remote branch
+                elif LOCAL_REMOTE_PREFIX + branch_bytes in refs:
+                    branch_refs[LOCAL_REMOTE_PREFIX + branch_bytes] = refs[
+                        LOCAL_REMOTE_PREFIX + branch_bytes
+                    ]
+        else:
+            # Default behavior: show local branches
+            if all_branches:
+                # Show both local and remote branches
+                branch_refs = filter_ref_prefix(
+                    refs, [LOCAL_BRANCH_PREFIX, LOCAL_REMOTE_PREFIX]
+                )
+            elif remotes:
+                # Show only remote branches
+                branch_refs = filter_ref_prefix(refs, [LOCAL_REMOTE_PREFIX])
+            else:
+                # Show only local branches
+                branch_refs = filter_ref_prefix(refs, [LOCAL_BRANCH_PREFIX])
+
+        # Add current branch if requested and not already included
+        if current:
+            try:
+                head_refs, _ = r.refs.follow(b"HEAD")
+                if head_refs:
+                    head_ref = head_refs[0]
+                    if head_ref not in branch_refs and head_ref in refs:
+                        branch_refs[head_ref] = refs[head_ref]
+            except (KeyError, TypeError):
+                # HEAD doesn't point to a branch or doesn't exist
+                pass
+
+        if not branch_refs:
+            return output_lines
+
+        # Sort branches for consistent output
+        sorted_branches = sorted(branch_refs.items(), key=lambda x: x[0])
+        branch_sha_list = [sha for _, sha in sorted_branches]
+
+        # Handle --independent flag
+        if independent_branches:
+            independent_shas = independent(r, branch_sha_list)
+            for ref_name, sha in sorted_branches:
+                if sha in independent_shas:
+                    ref_str = os.fsdecode(shorten_ref_name(ref_name))
+                    output_lines.append(ref_str)
+            return output_lines
+
+        # Handle --merge-base flag
+        if merge_base:
+            if len(branch_sha_list) < 2:
+                # Need at least 2 branches for merge base
+                return output_lines
+
+            merge_bases = find_octopus_base(r, branch_sha_list)
+            for sha in merge_bases:
+                output_lines.append(sha.decode("ascii"))
+            return output_lines
+
+        # Get current branch for marking
+        current_branch: Optional[bytes] = None
+        try:
+            head_refs, _ = r.refs.follow(b"HEAD")
+            if head_refs:
+                current_branch = head_refs[0]
+        except (KeyError, TypeError):
+            pass
+
+        # Collect commit information for each branch
+        branch_commits: list[tuple[bytes, str]] = []  # (sha, message)
+        for ref_name, sha in sorted_branches:
+            try:
+                commit = r[sha]
+                if hasattr(commit, "message"):
+                    message = commit.message.decode("utf-8", errors="replace").split(
+                        "\n"
+                    )[0]
+                else:
+                    message = ""
+                branch_commits.append((sha, message))
+            except KeyError:
+                branch_commits.append((sha, ""))
+
+        # Handle --list flag (show only branch headers)
+        if list_branches or (more is not None and more < 0):
+            # Just show the branch headers
+            for i, (ref_name, sha) in enumerate(sorted_branches):
+                is_current = ref_name == current_branch
+                marker = "*" if is_current else "!"
+                # Create spacing for alignment
+                prefix = " " * i + marker + " " * (len(sorted_branches) - i - 1)
+                ref_str = os.fsdecode(shorten_ref_name(ref_name))
+                _, message = branch_commits[i]
+                output_lines.append(f"{prefix}[{ref_str}] {message}")
+            return output_lines
+
+        # Build commit history for visualization
+        # Collect all commits reachable from any branch
+        all_commits: dict[
+            bytes, tuple[int, list[bytes], str]
+        ] = {}  # sha -> (timestamp, parents, message)
+
+        def collect_commits(sha: bytes, branch_idx: int, visited: set[bytes]) -> None:
+            """Recursively collect commits."""
+            if sha in visited:
+                return
+            visited.add(sha)
+
+            try:
+                commit = r[sha]
+                if not hasattr(commit, "commit_time"):
+                    return
+
+                timestamp = commit.commit_time
+                parents = commit.parents if hasattr(commit, "parents") else []
+                message = (
+                    commit.message.decode("utf-8", errors="replace").split("\n")[0]
+                    if hasattr(commit, "message")
+                    else ""
+                )
+
+                if sha not in all_commits:
+                    all_commits[sha] = (timestamp, parents, message)
+
+                # Recurse to parents
+                for parent in parents:
+                    collect_commits(parent, branch_idx, visited)
+            except KeyError:
+                # Commit not found, stop traversal
+                pass
+
+        # Collect commits from all branches
+        for i, (_, sha) in enumerate(sorted_branches):
+            collect_commits(sha, i, set())
+
+        # Find common ancestor
+        common_ancestor_sha = None
+        if len(branch_sha_list) >= 2:
+            try:
+                merge_bases = find_octopus_base(r, branch_sha_list)
+                if merge_bases:
+                    common_ancestor_sha = merge_bases[0]
+            except (KeyError, IndexError):
+                pass
+
+        # Sort commits (chronological by default, or topological if requested)
+        if topo_order:
+            # Topological sort is more complex, for now use chronological
+            # TODO: Implement proper topological ordering
+            sorted_commits = sorted(all_commits.items(), key=lambda x: -x[1][0])
+        else:
+            # Reverse chronological order (newest first)
+            sorted_commits = sorted(all_commits.items(), key=lambda x: -x[1][0])
+
+        # Determine how many commits to show
+        if more is not None:
+            # Find index of common ancestor
+            if common_ancestor_sha and common_ancestor_sha in all_commits:
+                ancestor_idx = next(
+                    (
+                        i
+                        for i, (sha, _) in enumerate(sorted_commits)
+                        if sha == common_ancestor_sha
+                    ),
+                    None,
+                )
+                if ancestor_idx is not None:
+                    # Show commits up to ancestor + more
+                    sorted_commits = sorted_commits[: ancestor_idx + 1 + more]
+
+        # Determine which branches contain which commits
+        branch_contains: list[set[bytes]] = []
+        for ref_name, sha in sorted_branches:
+            reachable = set()
+
+            def mark_reachable(commit_sha: bytes) -> None:
+                if commit_sha in reachable:
+                    return
+                reachable.add(commit_sha)
+                if commit_sha in all_commits:
+                    _, parents, _ = all_commits[commit_sha]
+                    for parent in parents:
+                        mark_reachable(parent)
+
+            mark_reachable(sha)
+            branch_contains.append(reachable)
+
+        # Output branch headers
+        for i, (ref_name, sha) in enumerate(sorted_branches):
+            is_current = ref_name == current_branch
+            marker = "*" if is_current else "!"
+            # Create spacing for alignment
+            prefix = " " * i + marker + " " * (len(sorted_branches) - i - 1)
+            ref_str = os.fsdecode(shorten_ref_name(ref_name))
+            _, message = branch_commits[i]
+            output_lines.append(f"{prefix}[{ref_str}] {message}")
+
+        # Output separator
+        output_lines.append("-" * (len(sorted_branches) + 2))
+
+        # Output commits
+        for commit_sha, (_, _, message) in sorted_commits:
+            # Build marker string
+            markers = []
+            for i, (ref_name, branch_sha) in enumerate(sorted_branches):
+                if commit_sha == branch_sha:
+                    # This is the tip of the branch
+                    markers.append("*")
+                elif commit_sha in branch_contains[i]:
+                    # This commit is in the branch
+                    markers.append("+")
+                else:
+                    # This commit is not in the branch
+                    markers.append(" ")
+
+            marker_str = "".join(markers)
+            output_lines.append(f"{marker_str} [{message}]")
+
+            # Limit output to 26 branches (git show-branch limitation)
+            if len(sorted_branches) > 26:
+                break
+
+    return output_lines
+
+
 def ls_remote(
     remote: Union[str, bytes],
     config: Optional[Config] = None,

+ 28 - 0
dulwich/refs.py

@@ -1468,6 +1468,34 @@ def is_local_branch(x: bytes) -> bool:
     return x.startswith(LOCAL_BRANCH_PREFIX)
 
 
+def shorten_ref_name(ref: bytes) -> bytes:
+    """Convert a full ref name to its short form.
+
+    Args:
+      ref: Full ref name (e.g., b"refs/heads/master")
+
+    Returns:
+      Short ref name (e.g., b"master")
+
+    Examples:
+      >>> shorten_ref_name(b"refs/heads/master")
+      b'master'
+      >>> shorten_ref_name(b"refs/remotes/origin/main")
+      b'origin/main'
+      >>> shorten_ref_name(b"refs/tags/v1.0")
+      b'v1.0'
+      >>> shorten_ref_name(b"HEAD")
+      b'HEAD'
+    """
+    if ref.startswith(LOCAL_BRANCH_PREFIX):
+        return ref[len(LOCAL_BRANCH_PREFIX) :]
+    elif ref.startswith(LOCAL_REMOTE_PREFIX):
+        return ref[len(LOCAL_REMOTE_PREFIX) :]
+    elif ref.startswith(LOCAL_TAG_PREFIX):
+        return ref[len(LOCAL_TAG_PREFIX) :]
+    return ref
+
+
 T = TypeVar("T", dict[bytes, bytes], dict[bytes, Optional[bytes]])
 
 

+ 367 - 0
tests/test_cli.py

@@ -1444,6 +1444,373 @@ 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 ShowBranchCommandTest(DulwichCliTestCase):
+    """Tests for show-branch command."""
+
+    def test_show_branch_basic(self):
+        """Test basic show-branch functionality."""
+        # Create initial commit
+        test_file = os.path.join(self.repo_path, "test.txt")
+        with open(test_file, "w") as f:
+            f.write("initial content")
+        self._run_cli("add", "test.txt")
+        self._run_cli("commit", "--message=Initial commit")
+
+        # Create a branch and add a commit
+        self._run_cli("branch", "branch1")
+        self._run_cli("checkout", "branch1")
+        with open(test_file, "a") as f:
+            f.write("\nbranch1 content")
+        self._run_cli("add", "test.txt")
+        self._run_cli("commit", "--message=Branch1 commit")
+
+        # Switch back to master
+        self._run_cli("checkout", "master")
+
+        # Run show-branch
+        with self.assertLogs("dulwich.cli", level="INFO") as cm:
+            _result, _stdout, _stderr = self._run_cli(
+                "show-branch", "master", "branch1"
+            )
+            output = "\n".join([record.message for record in cm.records])
+
+        # Check exact output
+        expected = (
+            "! [branch1] Branch1 commit\n"
+            " ![master] Initial commit\n"
+            "----\n"
+            "*  [Branch1 commit]\n"
+            "+* [Initial commit]"
+        )
+        self.assertEqual(expected, output)
+
+    def test_show_branch_list(self):
+        """Test show-branch with --list option."""
+        # Create initial commit
+        test_file = os.path.join(self.repo_path, "test.txt")
+        with open(test_file, "w") as f:
+            f.write("initial content")
+        self._run_cli("add", "test.txt")
+        self._run_cli("commit", "--message=Initial commit")
+
+        # Create branches
+        self._run_cli("branch", "branch1")
+        self._run_cli("branch", "branch2")
+
+        # Run show-branch --list
+        with self.assertLogs("dulwich.cli", level="INFO") as cm:
+            _result, _stdout, _stderr = self._run_cli("show-branch", "--list")
+            output = "\n".join([record.message for record in cm.records])
+
+        # Check exact output (only branch headers, no separator)
+        expected = (
+            "!  [branch1] Initial commit\n"
+            " ! [branch2] Initial commit\n"
+            "  ![master] Initial commit"
+        )
+        self.assertEqual(expected, output)
+
+    def test_show_branch_independent(self):
+        """Test show-branch with --independent option."""
+        # Create initial commit
+        test_file = os.path.join(self.repo_path, "test.txt")
+        with open(test_file, "w") as f:
+            f.write("initial content")
+        self._run_cli("add", "test.txt")
+        self._run_cli("commit", "--message=Initial commit")
+
+        # Create a branch and add a commit
+        self._run_cli("branch", "branch1")
+        self._run_cli("checkout", "branch1")
+        with open(test_file, "a") as f:
+            f.write("\nbranch1 content")
+        self._run_cli("add", "test.txt")
+        self._run_cli("commit", "--message=Branch1 commit")
+
+        # Run show-branch --independent
+        with self.assertLogs("dulwich.cli", level="INFO") as cm:
+            _result, _stdout, _stderr = self._run_cli(
+                "show-branch", "--independent", "master", "branch1"
+            )
+            output = "\n".join([record.message for record in cm.records])
+
+        # Only branch1 should be shown (it's not reachable from master)
+        expected = "branch1"
+        self.assertEqual(expected, output)
+
+    def test_show_branch_merge_base(self):
+        """Test show-branch with --merge-base option."""
+        # Create initial commit
+        test_file = os.path.join(self.repo_path, "test.txt")
+        with open(test_file, "w") as f:
+            f.write("initial content")
+        self._run_cli("add", "test.txt")
+        self._run_cli("commit", "--message=Initial commit")
+
+        # Get the initial commit SHA
+        initial_sha = self.repo.refs[b"HEAD"]
+
+        # Create a branch and add a commit
+        self._run_cli("branch", "branch1")
+        self._run_cli("checkout", "branch1")
+        with open(test_file, "a") as f:
+            f.write("\nbranch1 content")
+        self._run_cli("add", "test.txt")
+        self._run_cli("commit", "--message=Branch1 commit")
+
+        # Switch back to master and add a different commit
+        self._run_cli("checkout", "master")
+        with open(test_file, "a") as f:
+            f.write("\nmaster content")
+        self._run_cli("add", "test.txt")
+        self._run_cli("commit", "--message=Master commit")
+
+        # Run show-branch --merge-base
+        with self.assertLogs("dulwich.cli", level="INFO") as cm:
+            _result, _stdout, _stderr = self._run_cli(
+                "show-branch", "--merge-base", "master", "branch1"
+            )
+            output = "\n".join([record.message for record in cm.records])
+
+        # The merge base should be the initial commit SHA
+        expected = initial_sha.decode("ascii")
+        self.assertEqual(expected, output)
+
+
 class FormatPatchCommandTest(DulwichCliTestCase):
     """Tests for format-patch command."""
 

+ 41 - 0
tests/test_refs.py

@@ -41,6 +41,7 @@ from dulwich.refs import (
     parse_symref_value,
     read_packed_refs,
     read_packed_refs_with_peeled,
+    shorten_ref_name,
     split_peeled_refs,
     strip_peeled_refs,
     write_packed_refs,
@@ -1196,3 +1197,43 @@ class StripPeeledRefsTests(TestCase):
                 b"refs/tags/1.0.0": b"a93db4b0360cc635a2b93675010bac8d101f73f0",
             },
         )
+
+
+class ShortenRefNameTests(TestCase):
+    """Tests for shorten_ref_name function."""
+
+    def test_branch_ref(self) -> None:
+        """Test shortening branch references."""
+        self.assertEqual(b"master", shorten_ref_name(b"refs/heads/master"))
+        self.assertEqual(b"develop", shorten_ref_name(b"refs/heads/develop"))
+        self.assertEqual(
+            b"feature/new-ui", shorten_ref_name(b"refs/heads/feature/new-ui")
+        )
+
+    def test_remote_ref(self) -> None:
+        """Test shortening remote references."""
+        self.assertEqual(b"origin/main", shorten_ref_name(b"refs/remotes/origin/main"))
+        self.assertEqual(
+            b"upstream/master", shorten_ref_name(b"refs/remotes/upstream/master")
+        )
+        self.assertEqual(
+            b"origin/feature/test",
+            shorten_ref_name(b"refs/remotes/origin/feature/test"),
+        )
+
+    def test_tag_ref(self) -> None:
+        """Test shortening tag references."""
+        self.assertEqual(b"v1.0", shorten_ref_name(b"refs/tags/v1.0"))
+        self.assertEqual(b"release-2.0", shorten_ref_name(b"refs/tags/release-2.0"))
+
+    def test_special_refs(self) -> None:
+        """Test that special refs are not shortened."""
+        self.assertEqual(b"HEAD", shorten_ref_name(b"HEAD"))
+        self.assertEqual(b"FETCH_HEAD", shorten_ref_name(b"FETCH_HEAD"))
+        self.assertEqual(b"ORIG_HEAD", shorten_ref_name(b"ORIG_HEAD"))
+
+    def test_other_refs(self) -> None:
+        """Test refs that don't match standard prefixes."""
+        # Refs that don't match any standard prefix are returned as-is
+        self.assertEqual(b"refs/stash", shorten_ref_name(b"refs/stash"))
+        self.assertEqual(b"refs/bisect/good", shorten_ref_name(b"refs/bisect/good"))