Sfoglia il codice sorgente

Add support for git show-branch

Fixes #1829
Jelmer Vernooij 3 mesi fa
parent
commit
14b6d837f2
6 ha cambiato i file con 589 aggiunte e 1 eliminazioni
  1. 7 0
      NEWS
  2. 97 0
      dulwich/cli.py
  3. 283 1
      dulwich/porcelain.py
  4. 28 0
      dulwich/refs.py
  5. 133 0
      tests/test_cli.py
  6. 41 0
      tests/test_refs.py

+ 7 - 0
NEWS

@@ -1,3 +1,10 @@
+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

+ 97 - 0
dulwich/cli.py

@@ -1737,6 +1737,102 @@ class cmd_show_ref(Command):
         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."""
 
@@ -4835,6 +4931,7 @@ 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,

+ 283 - 1
dulwich/porcelain.py

@@ -192,6 +192,7 @@ from .refs import (
     SymrefLoop,
     _import_remote_refs,
     filter_ref_prefix,
+    shorten_ref_name,
 )
 from .repo import BaseRepo, Repo, get_user_identity
 from .server import (
@@ -3963,7 +3964,10 @@ def show_ref(
                             # 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]:
+                                if (
+                                    ref_parts[-(len(pattern_parts) - i)]
+                                    != pattern_parts[i]
+                                ):
                                     matches = False
                                     break
                             if matches:
@@ -3985,6 +3989,7 @@ def show_ref(
                     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
@@ -3997,6 +4002,283 @@ def show_ref(
     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]])
 
 

+ 133 - 0
tests/test_cli.py

@@ -1678,6 +1678,139 @@ class ShowRefCommandTest(DulwichCliTestCase):
         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"))