소스 검색

add --no-merged to branch command (#1874)

Implements the --no-merged flag for the dulwich branch command, 
which lists branches that have not been merged into the current branch
https://github.com/jelmer/dulwich/issues/1847
xifOO 4 달 전
부모
커밋
f05891827a
4개의 변경된 파일179개의 추가작업 그리고 9개의 파일을 삭제
  1. 18 1
      dulwich/cli.py
  2. 37 8
      dulwich/porcelain.py
  3. 36 0
      tests/test_cli.py
  4. 88 0
      tests/test_porcelain.py

+ 18 - 1
dulwich/cli.py

@@ -2060,7 +2060,12 @@ class cmd_branch(Command):
         )
         parser.add_argument("--all", action="store_true", help="List all branches")
         parser.add_argument(
-            "--merged", action="store_true", help="List merged branches"
+            "--merged", action="store_true", help="List merged into current branch"
+        )
+        parser.add_argument(
+            "--no-merged",
+            action="store_true",
+            help="List branches not merged into current branch",
         )
         parser.add_argument(
             "--remotes", action="store_true", help="List remotes branches"
@@ -2093,6 +2098,18 @@ class cmd_branch(Command):
                 sys.stderr.write(f"{e}")
                 return 1
 
+        if args.no_merged:
+            try:
+                branches_iter = porcelain.no_merged_branches(".")
+
+                for branch in branches_iter:
+                    sys.stdout.write(f"{branch.decode()}\n")
+
+                return 0
+            except porcelain.Error as e:
+                sys.stderr.write(f"{e}")
+                return 1
+
         if args.remotes:
             try:
                 branches = porcelain.branch_remotes_list(".")

+ 37 - 8
dulwich/porcelain.py

@@ -3307,26 +3307,55 @@ def branch_remotes_list(repo: RepoPath) -> list[bytes]:
         return branches
 
 
-def merged_branches(repo: RepoPath) -> Iterator[bytes]:
-    """List branches that have been merged into the current branch.
+def _get_branch_merge_status(repo: RepoPath) -> Iterator[tuple[bytes, bool]]:
+    """Get merge status for all branches relative to current HEAD.
 
     Args:
-      repo: Path to the repository
+        repo: Path to the repository
+
     Yields:
-      Branch names (without refs/heads/ prefix) that are merged
-      into the current HEAD
+        Tuple of (branch_name, is_merged) where:
+        - branch_name: Branch name without refs/heads/ prefix
+        - is_merged: True if branch is merged into HEAD, False otherwise
     """
     with open_repo_closing(repo) as r:
         current_sha = r.refs[b"HEAD"]
 
         for branch_ref in r.refs.keys(base=b"refs/heads/"):
             full_ref = b"refs/heads/" + branch_ref
-
             branch_sha = r.refs[full_ref]
 
             # Check if branch is an ancestor of HEAD (fully merged)
-            if can_fast_forward(r, branch_sha, current_sha):
-                yield branch_ref
+            is_merged = can_fast_forward(r, branch_sha, current_sha)
+            yield branch_ref, is_merged
+
+
+def merged_branches(repo: RepoPath) -> Iterator[bytes]:
+    """List branches that have been merged into the current branch.
+
+    Args:
+      repo: Path to the repository
+    Yields:
+      Branch names (without refs/heads/ prefix) that are merged
+      into the current HEAD
+    """
+    for branch_name, is_merged in _get_branch_merge_status(repo):
+        if is_merged:
+            yield branch_name
+
+
+def no_merged_branches(repo: RepoPath) -> Iterator[bytes]:
+    """List branches that have been merged into the current branch.
+
+    Args:
+      repo: Path to the repository
+    Yields:
+      Branch names (without refs/heads/ prefix) that are merged
+      into the current HEAD
+    """
+    for branch_name, is_merged in _get_branch_merge_status(repo):
+        if not is_merged:
+            yield branch_name
 
 
 def active_branch(repo: RepoPath) -> bytes:

+ 36 - 0
tests/test_cli.py

@@ -495,6 +495,42 @@ class BranchCommandTest(DulwichCliTestCase):
         expected_branches = {"master", "merged-branch"}
         self.assertEqual(set(branches), expected_branches)
 
+    def test_branch_list_no_merged(self):
+        # Create initial commit
+        test_file = os.path.join(self.repo_path, "test.txt")
+        with open(test_file, "w") as f:
+            f.write("test")
+        self._run_cli("add", "test.txt")
+        self._run_cli("commit", "--message=Initial")
+
+        master_sha = self.repo.refs[b"refs/heads/master"]
+
+        # Create a merged branch (points to same commit as master)
+        self.repo.refs[b"refs/heads/merged-branch"] = master_sha
+
+        # Create a new branch with different content (not merged)
+        test_file2 = os.path.join(self.repo_path, "test2.txt")
+        with open(test_file2, "w") as f:
+            f.write("test2")
+        self._run_cli("add", "test2.txt")
+        self._run_cli("commit", "--message=New branch commit")
+        new_branch_sha = self.repo.refs[b"HEAD"]
+
+        # Switch back to master
+        self.repo.refs[b"HEAD"] = master_sha
+
+        # Create a non-merged branch that points to the new branch commit
+        self.repo.refs[b"refs/heads/non-merged-branch"] = new_branch_sha
+
+        # Test --no-merged listing
+        result, stdout, stderr = self._run_cli("branch", "--no-merged")
+        self.assertEqual(result, 0)
+
+        branches = [line.strip() for line in stdout.splitlines()]
+        expected_branches = {"non-merged-branch"}
+
+        self.assertEqual(set(branches), expected_branches)
+
     def test_branch_list_remotes(self):
         # Create initial commit
         test_file = os.path.join(self.repo_path, "test.txt")

+ 88 - 0
tests/test_porcelain.py

@@ -6789,6 +6789,94 @@ class BranchMergedTests(PorcelainTestCase):
         self.assertEqual([b"master"], result)
 
 
+class BranchNoMergedTests(PorcelainTestCase):
+    def test_all_branches_merged(self) -> None:
+        """Test when all branches are merged - should return empty list."""
+        # Create linear history: c1 → c2 → c3 (HEAD)
+        [c1, c2, c3] = build_commit_graph(self.repo.object_store, [[1], [2, 1], [3, 2]])
+        self.repo.refs[b"HEAD"] = c3.id
+        self.repo.refs[b"refs/heads/master"] = c3.id
+        self.repo.refs[b"refs/heads/feature-1"] = c2.id  # Merged (ancestor)
+        self.repo.refs[b"refs/heads/feature-2"] = c1.id  # Merged (ancestor)
+
+        result = list(porcelain.no_merged_branches(self.repo))
+        self.assertEqual([], result)
+
+    def test_no_merged_branches(self) -> None:
+        """Test with some non-merged branches."""
+        # Create complete graph: c1 → c2 (master), c1 → c3 (feature)
+        [c1, c2, c3] = build_commit_graph(
+            self.repo.object_store,
+            [
+                [1],  # c1
+                [2, 1],  # c2 → c1 (master line)
+                [3, 1],  # c3 → c1 (diverged feature branch)
+            ],
+        )
+        self.repo.refs[b"HEAD"] = c2.id
+        self.repo.refs[b"refs/heads/master"] = c2.id
+        self.repo.refs[b"refs/heads/feature"] = c3.id
+
+        result = list(porcelain.no_merged_branches(self.repo))
+        self.assertEqual([b"feature"], result)
+
+    def test_some_branches_not_merged(self) -> None:
+        """Test when some branches are merged, some are not."""
+        # c1 → c2 → c3 (HEAD/master)
+        # c1 → c4 → c5 (feature-1 - diverged)
+        [c1, c2, c3, c4, c5] = build_commit_graph(
+            self.repo.object_store,
+            [
+                [1],  # c1
+                [2, 1],  # c2 → c1 (master line)
+                [3, 2],  # c3 → c2 (HEAD)
+                [4, 1],  # c4 → c1 (feature branch)
+                [5, 4],  # c5 → c4 (feature branch)
+            ],
+        )
+        self.repo.refs[b"HEAD"] = c3.id
+        self.repo.refs[b"refs/heads/master"] = c3.id
+        self.repo.refs[b"refs/heads/feature-1"] = c5.id  # Not merged (diverged)
+        self.repo.refs[b"refs/heads/feature-2"] = c2.id  # Merged (ancestor)
+
+        result = list(porcelain.no_merged_branches(self.repo))
+        self.assertEqual([b"feature-1"], result)
+
+    def test_multiple_branches_not_merged(self) -> None:
+        """Test with multiple non-merged branches."""
+        # c1 → c2 (HEAD/master)
+        # c1 → c3 (feature-1 - diverged)
+        # c1 → c4 (feature-2 - diverged)
+        [c1, c2, c3, c4] = build_commit_graph(
+            self.repo.object_store,
+            [
+                [1],  # c1
+                [2, 1],  # c2 → c1 (master line)
+                [3, 1],  # c3 → c1 (feature-1 branch)
+                [4, 1],  # c4 → c1 (feature-2 branch)
+            ],
+        )
+        self.repo.refs[b"HEAD"] = c2.id
+        self.repo.refs[b"refs/heads/master"] = c2.id
+        self.repo.refs[b"refs/heads/feature-1"] = c3.id  # Not merged (diverged)
+        self.repo.refs[b"refs/heads/feature-2"] = c4.id  # Not merged (diverged)
+
+        branches = list(porcelain.no_merged_branches(self.repo))
+        expected = [b"feature-1", b"feature-2"]
+        expected.sort()
+        branches.sort()
+        self.assertEqual(expected, branches)
+
+    def test_only_current_branch_exists(self) -> None:
+        """Test when only current branch exists - should return empty list."""
+        [c1] = build_commit_graph(self.repo.object_store, [[1]])
+        self.repo.refs[b"HEAD"] = c1.id
+        self.repo.refs[b"refs/heads/master"] = c1.id
+
+        result = list(porcelain.no_merged_branches(self.repo))
+        self.assertEqual([], result)
+
+
 class BranchCreateTests(PorcelainTestCase):
     def test_branch_exists(self) -> None:
         [c1] = build_commit_graph(self.repo.object_store, [[1]])