Prechádzať zdrojové kódy

Merge branch 'master' into recursive-merge

Jelmer Vernooij 3 mesiacov pred
rodič
commit
bcf237d786
7 zmenil súbory, kde vykonal 852 pridanie a 22 odobranie
  1. 4 0
      NEWS
  2. 27 9
      dulwich/cli.py
  3. 90 0
      dulwich/merge.py
  4. 173 13
      dulwich/porcelain.py
  5. 159 0
      tests/test_cli_merge.py
  6. 204 0
      tests/test_merge.py
  7. 195 0
      tests/test_porcelain_merge.py

+ 4 - 0
NEWS

@@ -1,5 +1,9 @@
 0.24.6	2025-10-17
 0.24.6	2025-10-17
 
 
+ * Add support for octopus merge strategy. (Jelmer Vernooij, #1816)
+
+ * Add support for ``git show-branch`` command to display branches and their
+
  * Add ``dulwich cherry`` command to find commits not merged upstream.
  * Add ``dulwich cherry`` command to find commits not merged upstream.
    Compares commits by patch ID to identify equivalent patches regardless of
    Compares commits by patch ID to identify equivalent patches regardless of
    commit metadata. Supports automatic upstream detection from tracking branches
    commit metadata. Supports automatic upstream detection from tracking branches

+ 27 - 9
dulwich/cli.py

@@ -3359,7 +3359,7 @@ class cmd_merge(Command):
             args: Command line arguments
             args: Command line arguments
         """
         """
         parser = argparse.ArgumentParser()
         parser = argparse.ArgumentParser()
-        parser.add_argument("commit", type=str, help="Commit to merge")
+        parser.add_argument("commit", type=str, nargs="+", help="Commit(s) to merge")
         parser.add_argument(
         parser.add_argument(
             "--no-commit", action="store_true", help="Do not create a merge commit"
             "--no-commit", action="store_true", help="Do not create a merge commit"
         )
         )
@@ -3370,9 +3370,16 @@ class cmd_merge(Command):
         parsed_args = parser.parse_args(args)
         parsed_args = parser.parse_args(args)
 
 
         try:
         try:
+            # If multiple commits are provided, pass them as a list
+            # If only one commit is provided, pass it as a string
+            if len(parsed_args.commit) == 1:
+                committish = parsed_args.commit[0]
+            else:
+                committish = parsed_args.commit
+
             merge_commit_id, conflicts = porcelain.merge(
             merge_commit_id, conflicts = porcelain.merge(
                 ".",
                 ".",
-                parsed_args.commit,
+                committish,
                 no_commit=parsed_args.no_commit,
                 no_commit=parsed_args.no_commit,
                 no_ff=parsed_args.no_ff,
                 no_ff=parsed_args.no_ff,
                 message=parsed_args.message,
                 message=parsed_args.message,
@@ -3382,9 +3389,14 @@ class cmd_merge(Command):
                 logger.warning("Merge conflicts in %d file(s):", len(conflicts))
                 logger.warning("Merge conflicts in %d file(s):", len(conflicts))
                 for conflict_path in conflicts:
                 for conflict_path in conflicts:
                     logger.warning("  %s", conflict_path.decode())
                     logger.warning("  %s", conflict_path.decode())
-                logger.error(
-                    "Automatic merge failed; fix conflicts and then commit the result."
-                )
+                if len(parsed_args.commit) > 1:
+                    logger.error(
+                        "Octopus merge failed; refusing to merge with conflicts."
+                    )
+                else:
+                    logger.error(
+                        "Automatic merge failed; fix conflicts and then commit the result."
+                    )
                 return 1
                 return 1
             elif merge_commit_id is None and not parsed_args.no_commit:
             elif merge_commit_id is None and not parsed_args.no_commit:
                 logger.info("Already up to date.")
                 logger.info("Already up to date.")
@@ -3392,10 +3404,16 @@ class cmd_merge(Command):
                 logger.info("Automatic merge successful; not committing as requested.")
                 logger.info("Automatic merge successful; not committing as requested.")
             else:
             else:
                 assert merge_commit_id is not None
                 assert merge_commit_id is not None
-                logger.info(
-                    "Merge successful. Created merge commit %s",
-                    merge_commit_id.decode(),
-                )
+                if len(parsed_args.commit) > 1:
+                    logger.info(
+                        "Octopus merge successful. Created merge commit %s",
+                        merge_commit_id.decode(),
+                    )
+                else:
+                    logger.info(
+                        "Merge successful. Created merge commit %s",
+                        merge_commit_id.decode(),
+                    )
             return 0
             return 0
         except porcelain.Error as e:
         except porcelain.Error as e:
             logger.error("%s", e)
             logger.error("%s", e)

+ 90 - 0
dulwich/merge.py

@@ -667,3 +667,93 @@ def three_way_merge(
     assert isinstance(ours_tree, Tree)
     assert isinstance(ours_tree, Tree)
     assert isinstance(theirs_tree, Tree)
     assert isinstance(theirs_tree, Tree)
     return merger.merge_trees(base_tree, ours_tree, theirs_tree)
     return merger.merge_trees(base_tree, ours_tree, theirs_tree)
+
+
+def octopus_merge(
+    object_store: BaseObjectStore,
+    merge_bases: list[bytes],
+    head_commit: Commit,
+    other_commits: list[Commit],
+    gitattributes: Optional[GitAttributes] = None,
+    config: Optional[Config] = None,
+) -> tuple[Tree, list[bytes]]:
+    """Perform an octopus merge of multiple commits.
+
+    The octopus merge strategy merges multiple branches sequentially into a single
+    commit with multiple parents. It refuses to proceed if any merge would result
+    in conflicts that require manual resolution.
+
+    Args:
+        object_store: Object store to read/write objects
+        merge_bases: List of common ancestor commit IDs for all commits
+        head_commit: Current HEAD commit (ours)
+        other_commits: List of commits to merge (theirs)
+        gitattributes: Optional GitAttributes object for checking merge drivers
+        config: Optional Config object for loading merge driver configuration
+
+    Returns:
+        tuple of (merged_tree, list_of_conflicted_paths)
+        If any conflicts occur during the sequential merges, the function returns
+        early with the conflicts list populated.
+
+    Raises:
+        TypeError: If any object is not of the expected type
+    """
+    if not other_commits:
+        raise ValueError("octopus_merge requires at least one commit to merge")
+
+    # Start with the head commit's tree as our current state
+    current_commit = head_commit
+
+    # Merge each commit sequentially
+    for i, other_commit in enumerate(other_commits):
+        # Find the merge base between current state and the commit we're merging
+        # For octopus merges, we use the octopus base for all commits
+        if merge_bases:
+            base_commit_id = merge_bases[0]
+            base_commit = object_store[base_commit_id]
+            if not isinstance(base_commit, Commit):
+                raise TypeError(f"Expected Commit, got {type(base_commit)}")
+        else:
+            base_commit = None
+
+        # Perform three-way merge
+        merged_tree, conflicts = three_way_merge(
+            object_store,
+            base_commit,
+            current_commit,
+            other_commit,
+            gitattributes,
+            config,
+        )
+
+        # Octopus merge refuses to proceed if there are conflicts
+        if conflicts:
+            return merged_tree, conflicts
+
+        # Add merged tree to object store
+        object_store.add_object(merged_tree)
+
+        # Create a temporary commit object with the merged tree for the next iteration
+        # This allows us to continue merging additional commits
+        if i < len(other_commits) - 1:
+            temp_commit = Commit()
+            temp_commit.tree = merged_tree.id
+            # For intermediate merges, we use the same parent as current
+            temp_commit.parents = (
+                current_commit.parents
+                if current_commit.parents
+                else [current_commit.id]
+            )
+            # Set minimal required commit fields
+            temp_commit.author = current_commit.author
+            temp_commit.committer = current_commit.committer
+            temp_commit.author_time = current_commit.author_time
+            temp_commit.commit_time = current_commit.commit_time
+            temp_commit.author_timezone = current_commit.author_timezone
+            temp_commit.commit_timezone = current_commit.commit_timezone
+            temp_commit.message = b"Temporary octopus merge commit"
+            object_store.add_object(temp_commit)
+            current_commit = temp_commit
+
+    return merged_tree, []

+ 173 - 13
dulwich/porcelain.py

@@ -5430,20 +5430,155 @@ def _do_merge(
     return (merge_commit_obj.id, [])
     return (merge_commit_obj.id, [])
 
 
 
 
+def _do_octopus_merge(
+    r: Repo,
+    merge_commit_ids: list[bytes],
+    no_commit: bool = False,
+    no_ff: bool = False,
+    message: Optional[bytes] = None,
+    author: Optional[bytes] = None,
+    committer: Optional[bytes] = None,
+) -> tuple[Optional[bytes], list[bytes]]:
+    """Internal octopus merge implementation that operates on an open repository.
+
+    Args:
+      r: Open repository object
+      merge_commit_ids: List of commit SHAs to merge
+      no_commit: If True, do not create a merge commit
+      no_ff: If True, force creation of a merge commit (ignored for octopus)
+      message: Optional merge commit message
+      author: Optional author for merge commit
+      committer: Optional committer for merge commit
+
+    Returns:
+      Tuple of (merge_commit_sha, conflicts) where merge_commit_sha is None
+      if no_commit=True or there were conflicts
+    """
+    from .graph import find_octopus_base
+    from .merge import octopus_merge
+
+    # Get HEAD commit
+    try:
+        head_commit_id = r.refs[b"HEAD"]
+    except KeyError:
+        raise Error("No HEAD reference found")
+
+    head_commit = r[head_commit_id]
+    assert isinstance(head_commit, Commit), "Expected a Commit object"
+
+    # Get all commits to merge
+    other_commits = []
+    for merge_commit_id in merge_commit_ids:
+        merge_commit = r[merge_commit_id]
+        assert isinstance(merge_commit, Commit), "Expected a Commit object"
+
+        # Check if we're trying to merge the same commit as HEAD
+        if head_commit_id == merge_commit_id:
+            # Skip this commit, it's already merged
+            continue
+
+        other_commits.append(merge_commit)
+
+    # If no commits to merge after filtering, we're already up to date
+    if not other_commits:
+        return (None, [])
+
+    # If only one commit to merge, use regular merge
+    if len(other_commits) == 1:
+        return _do_merge(
+            r, other_commits[0].id, no_commit, no_ff, message, author, committer
+        )
+
+    # Find the octopus merge base
+    all_commit_ids = [head_commit_id] + [c.id for c in other_commits]
+    merge_bases = find_octopus_base(r, all_commit_ids)
+
+    if not merge_bases:
+        raise Error("No common ancestor found")
+
+    # Check if this is a fast-forward (HEAD is the merge base)
+    # For octopus merges, fast-forward doesn't really apply, so we always create a merge commit
+
+    # Perform octopus merge
+    gitattributes = r.get_gitattributes()
+    config = r.get_config()
+    merged_tree, conflicts = octopus_merge(
+        r.object_store, merge_bases, head_commit, other_commits, gitattributes, config
+    )
+
+    # Add merged tree to object store
+    r.object_store.add_object(merged_tree)
+
+    # Update index and working directory
+    changes = tree_changes(r.object_store, head_commit.tree, merged_tree.id)
+    update_working_tree(r, head_commit.tree, merged_tree.id, change_iterator=changes)
+
+    if conflicts:
+        # Don't create a commit if there are conflicts
+        # Octopus merge refuses to proceed with conflicts
+        return (None, conflicts)
+
+    if no_commit:
+        # Don't create a commit if no_commit is True
+        return (None, [])
+
+    # Create merge commit with multiple parents
+    merge_commit_obj = Commit()
+    merge_commit_obj.tree = merged_tree.id
+    merge_commit_obj.parents = [head_commit_id] + [c.id for c in other_commits]
+
+    # Set author/committer
+    if author is None:
+        author = get_user_identity(r.get_config_stack())
+    if committer is None:
+        committer = author
+
+    merge_commit_obj.author = author
+    merge_commit_obj.committer = committer
+
+    # Set timestamps
+    timestamp = int(time.time())
+    timezone = 0  # UTC
+    merge_commit_obj.author_time = timestamp
+    merge_commit_obj.author_timezone = timezone
+    merge_commit_obj.commit_time = timestamp
+    merge_commit_obj.commit_timezone = timezone
+
+    # Set commit message
+    if message is None:
+        # Generate default message for octopus merge
+        branch_names = []
+        for commit_id in merge_commit_ids:
+            branch_names.append(commit_id.decode()[:7])
+        message = f"Merge commits {', '.join(branch_names)}\n".encode()
+    merge_commit_obj.message = message.encode() if isinstance(message, str) else message
+
+    # Add commit to object store
+    r.object_store.add_object(merge_commit_obj)
+
+    # Update HEAD
+    r.refs[b"HEAD"] = merge_commit_obj.id
+
+    return (merge_commit_obj.id, [])
+
+
 def merge(
 def merge(
     repo: Union[str, os.PathLike[str], Repo],
     repo: Union[str, os.PathLike[str], Repo],
-    committish: Union[str, bytes, Commit, Tag],
+    committish: Union[
+        str, bytes, Commit, Tag, Sequence[Union[str, bytes, Commit, Tag]]
+    ],
     no_commit: bool = False,
     no_commit: bool = False,
     no_ff: bool = False,
     no_ff: bool = False,
     message: Optional[bytes] = None,
     message: Optional[bytes] = None,
     author: Optional[bytes] = None,
     author: Optional[bytes] = None,
     committer: Optional[bytes] = None,
     committer: Optional[bytes] = None,
 ) -> tuple[Optional[bytes], list[bytes]]:
 ) -> tuple[Optional[bytes], list[bytes]]:
-    """Merge a commit into the current branch.
+    """Merge one or more commits into the current branch.
 
 
     Args:
     Args:
       repo: Repository to merge into
       repo: Repository to merge into
-      committish: Commit to merge
+      committish: Commit(s) to merge. Can be a single commit or a sequence of commits.
+                  When merging more than two heads, the octopus merge strategy is used.
       no_commit: If True, do not create a merge commit
       no_commit: If True, do not create a merge commit
       no_ff: If True, force creation of a merge commit
       no_ff: If True, force creation of a merge commit
       message: Optional merge commit message
       message: Optional merge commit message
@@ -5458,17 +5593,42 @@ def merge(
       Error: If there is no HEAD reference or commit cannot be found
       Error: If there is no HEAD reference or commit cannot be found
     """
     """
     with open_repo_closing(repo) as r:
     with open_repo_closing(repo) as r:
-        # Parse the commit to merge
-        try:
-            merge_commit_id = parse_commit(r, committish).id
-        except KeyError:
-            raise Error(
-                f"Cannot find commit '{committish.decode() if isinstance(committish, bytes) else committish}'"
-            )
+        # Handle both single commit and multiple commits
+        if isinstance(committish, (list, tuple)):
+            # Multiple commits - use octopus merge
+            merge_commit_ids = []
+            for c in committish:
+                try:
+                    merge_commit_ids.append(parse_commit(r, c).id)
+                except KeyError:
+                    raise Error(
+                        f"Cannot find commit '{c.decode() if isinstance(c, bytes) else c}'"
+                    )
 
 
-        result = _do_merge(
-            r, merge_commit_id, no_commit, no_ff, message, author, committer
-        )
+            if len(merge_commit_ids) == 1:
+                # Only one commit, use regular merge
+                result = _do_merge(
+                    r, merge_commit_ids[0], no_commit, no_ff, message, author, committer
+                )
+            else:
+                # Multiple commits, use octopus merge
+                result = _do_octopus_merge(
+                    r, merge_commit_ids, no_commit, no_ff, message, author, committer
+                )
+        else:
+            # Single commit - use regular merge
+            # Type narrowing: committish is not a sequence in this branch
+            single_committish = cast(Union[str, bytes, Commit, Tag], committish)
+            try:
+                merge_commit_id = parse_commit(r, single_committish).id
+            except KeyError:
+                raise Error(
+                    f"Cannot find commit '{single_committish.decode() if isinstance(single_committish, bytes) else single_committish}'"
+                )
+
+            result = _do_merge(
+                r, merge_commit_id, no_commit, no_ff, message, author, committer
+            )
 
 
         # Trigger auto GC if needed
         # Trigger auto GC if needed
         from .gc import maybe_auto_gc
         from .gc import maybe_auto_gc

+ 159 - 0
tests/test_cli_merge.py

@@ -268,6 +268,165 @@ class CLIMergeTests(TestCase):
             finally:
             finally:
                 os.chdir(old_cwd)
                 os.chdir(old_cwd)
 
 
+    def test_octopus_merge_three_branches(self):
+        """Test CLI octopus merge with three branches."""
+        with tempfile.TemporaryDirectory() as tmpdir:
+            # Initialize repo
+            porcelain.init(tmpdir)
+
+            # Create initial commit with three files
+            with open(os.path.join(tmpdir, "file1.txt"), "w") as f:
+                f.write("File 1 content\n")
+            with open(os.path.join(tmpdir, "file2.txt"), "w") as f:
+                f.write("File 2 content\n")
+            with open(os.path.join(tmpdir, "file3.txt"), "w") as f:
+                f.write("File 3 content\n")
+            porcelain.add(tmpdir, paths=["file1.txt", "file2.txt", "file3.txt"])
+            porcelain.commit(tmpdir, message=b"Initial commit")
+
+            # Create branch1 and modify file1
+            porcelain.branch_create(tmpdir, "branch1")
+            porcelain.checkout(tmpdir, "branch1")
+            with open(os.path.join(tmpdir, "file1.txt"), "w") as f:
+                f.write("Branch1 modified file1\n")
+            porcelain.add(tmpdir, paths=["file1.txt"])
+            porcelain.commit(tmpdir, message=b"Branch1 modifies file1")
+
+            # Create branch2 and modify file2
+            porcelain.checkout(tmpdir, "master")
+            porcelain.branch_create(tmpdir, "branch2")
+            porcelain.checkout(tmpdir, "branch2")
+            with open(os.path.join(tmpdir, "file2.txt"), "w") as f:
+                f.write("Branch2 modified file2\n")
+            porcelain.add(tmpdir, paths=["file2.txt"])
+            porcelain.commit(tmpdir, message=b"Branch2 modifies file2")
+
+            # Create branch3 and modify file3
+            porcelain.checkout(tmpdir, "master")
+            porcelain.branch_create(tmpdir, "branch3")
+            porcelain.checkout(tmpdir, "branch3")
+            with open(os.path.join(tmpdir, "file3.txt"), "w") as f:
+                f.write("Branch3 modified file3\n")
+            porcelain.add(tmpdir, paths=["file3.txt"])
+            porcelain.commit(tmpdir, message=b"Branch3 modifies file3")
+
+            # Go back to master and octopus merge all three branches via CLI
+            porcelain.checkout(tmpdir, "master")
+            old_cwd = os.getcwd()
+            try:
+                os.chdir(tmpdir)
+                with self.assertLogs("dulwich.cli", level="INFO") as cm:
+                    ret = main(["merge", "branch1", "branch2", "branch3"])
+                    log_output = "\n".join(cm.output)
+
+                self.assertEqual(ret, 0)  # Success
+                self.assertIn("Octopus merge successful", log_output)
+
+                # Check that all modifications are present
+                with open(os.path.join(tmpdir, "file1.txt")) as f:
+                    self.assertEqual(f.read(), "Branch1 modified file1\n")
+                with open(os.path.join(tmpdir, "file2.txt")) as f:
+                    self.assertEqual(f.read(), "Branch2 modified file2\n")
+                with open(os.path.join(tmpdir, "file3.txt")) as f:
+                    self.assertEqual(f.read(), "Branch3 modified file3\n")
+            finally:
+                os.chdir(old_cwd)
+
+    def test_octopus_merge_with_conflicts(self):
+        """Test CLI octopus merge with conflicts."""
+        with tempfile.TemporaryDirectory() as tmpdir:
+            # Initialize repo
+            porcelain.init(tmpdir)
+
+            # Create initial commit
+            with open(os.path.join(tmpdir, "file1.txt"), "w") as f:
+                f.write("Initial content\n")
+            porcelain.add(tmpdir, paths=["file1.txt"])
+            porcelain.commit(tmpdir, message=b"Initial commit")
+
+            # Create branch1 and modify file1
+            porcelain.branch_create(tmpdir, "branch1")
+            porcelain.checkout(tmpdir, "branch1")
+            with open(os.path.join(tmpdir, "file1.txt"), "w") as f:
+                f.write("Branch1 content\n")
+            porcelain.add(tmpdir, paths=["file1.txt"])
+            porcelain.commit(tmpdir, message=b"Branch1 modifies file1")
+
+            # Create branch2 and modify file1 differently
+            porcelain.checkout(tmpdir, "master")
+            porcelain.branch_create(tmpdir, "branch2")
+            porcelain.checkout(tmpdir, "branch2")
+            with open(os.path.join(tmpdir, "file1.txt"), "w") as f:
+                f.write("Branch2 content\n")
+            porcelain.add(tmpdir, paths=["file1.txt"])
+            porcelain.commit(tmpdir, message=b"Branch2 modifies file1")
+
+            # Go back to master and try octopus merge via CLI - should fail
+            porcelain.checkout(tmpdir, "master")
+            old_cwd = os.getcwd()
+            try:
+                os.chdir(tmpdir)
+                with self.assertLogs("dulwich.cli", level="WARNING") as cm:
+                    ret = main(["merge", "branch1", "branch2"])
+                    log_output = "\n".join(cm.output)
+
+                self.assertEqual(ret, 1)  # Error
+                self.assertIn("Octopus merge failed", log_output)
+                self.assertIn("refusing to merge with conflicts", log_output)
+            finally:
+                os.chdir(old_cwd)
+
+    def test_octopus_merge_no_commit(self):
+        """Test CLI octopus merge with --no-commit."""
+        with tempfile.TemporaryDirectory() as tmpdir:
+            # Initialize repo
+            porcelain.init(tmpdir)
+
+            # Create initial commit
+            with open(os.path.join(tmpdir, "file1.txt"), "w") as f:
+                f.write("File 1 content\n")
+            with open(os.path.join(tmpdir, "file2.txt"), "w") as f:
+                f.write("File 2 content\n")
+            porcelain.add(tmpdir, paths=["file1.txt", "file2.txt"])
+            porcelain.commit(tmpdir, message=b"Initial commit")
+
+            # Create branch1 and modify file1
+            porcelain.branch_create(tmpdir, "branch1")
+            porcelain.checkout(tmpdir, "branch1")
+            with open(os.path.join(tmpdir, "file1.txt"), "w") as f:
+                f.write("Branch1 modified file1\n")
+            porcelain.add(tmpdir, paths=["file1.txt"])
+            porcelain.commit(tmpdir, message=b"Branch1 modifies file1")
+
+            # Create branch2 and modify file2
+            porcelain.checkout(tmpdir, "master")
+            porcelain.branch_create(tmpdir, "branch2")
+            porcelain.checkout(tmpdir, "branch2")
+            with open(os.path.join(tmpdir, "file2.txt"), "w") as f:
+                f.write("Branch2 modified file2\n")
+            porcelain.add(tmpdir, paths=["file2.txt"])
+            porcelain.commit(tmpdir, message=b"Branch2 modifies file2")
+
+            # Go back to master and octopus merge with --no-commit
+            porcelain.checkout(tmpdir, "master")
+            old_cwd = os.getcwd()
+            try:
+                os.chdir(tmpdir)
+                with self.assertLogs("dulwich.cli", level="INFO") as cm:
+                    ret = main(["merge", "--no-commit", "branch1", "branch2"])
+                    log_output = "\n".join(cm.output)
+
+                self.assertEqual(ret, 0)  # Success
+                self.assertIn("not committing", log_output)
+
+                # Check that files are merged
+                with open(os.path.join(tmpdir, "file1.txt")) as f:
+                    self.assertEqual(f.read(), "Branch1 modified file1\n")
+                with open(os.path.join(tmpdir, "file2.txt")) as f:
+                    self.assertEqual(f.read(), "Branch2 modified file2\n")
+            finally:
+                os.chdir(old_cwd)
+
 
 
 if __name__ == "__main__":
 if __name__ == "__main__":
     unittest.main()
     unittest.main()

+ 204 - 0
tests/test_merge.py

@@ -742,3 +742,207 @@ class RecursiveMergeTests(unittest.TestCase):
         self.assertEqual(len(conflicts), 0)
         self.assertEqual(len(conflicts), 0)
         # Merged tree should be empty
         # Merged tree should be empty
         self.assertEqual(len(list(merged_tree.items())), 0)
         self.assertEqual(len(list(merged_tree.items())), 0)
+
+
+class OctopusMergeTests(unittest.TestCase):
+    """Tests for octopus merge functionality."""
+
+    def setUp(self):
+        self.repo = MemoryRepo()
+        # Check if merge3 module is available
+        if importlib.util.find_spec("merge3") is None:
+            raise DependencyMissing("merge3")
+
+    def test_octopus_merge_three_branches(self):
+        """Test octopus merge with three branches."""
+        from dulwich.merge import octopus_merge
+
+        # Create base commit
+        base_tree = Tree()
+        blob1 = Blob.from_string(b"file1 content\n")
+        blob2 = Blob.from_string(b"file2 content\n")
+        blob3 = Blob.from_string(b"file3 content\n")
+        self.repo.object_store.add_object(blob1)
+        self.repo.object_store.add_object(blob2)
+        self.repo.object_store.add_object(blob3)
+        base_tree.add(b"file1.txt", 0o100644, blob1.id)
+        base_tree.add(b"file2.txt", 0o100644, blob2.id)
+        base_tree.add(b"file3.txt", 0o100644, blob3.id)
+        self.repo.object_store.add_object(base_tree)
+
+        base_commit = Commit()
+        base_commit.tree = base_tree.id
+        base_commit.author = b"Test <test@example.com>"
+        base_commit.committer = b"Test <test@example.com>"
+        base_commit.message = b"Base commit"
+        base_commit.commit_time = base_commit.author_time = 12345
+        base_commit.commit_timezone = base_commit.author_timezone = 0
+        self.repo.object_store.add_object(base_commit)
+
+        # Create HEAD commit (modifies file1)
+        head_tree = Tree()
+        head_blob1 = Blob.from_string(b"file1 modified by head\n")
+        self.repo.object_store.add_object(head_blob1)
+        head_tree.add(b"file1.txt", 0o100644, head_blob1.id)
+        head_tree.add(b"file2.txt", 0o100644, blob2.id)
+        head_tree.add(b"file3.txt", 0o100644, blob3.id)
+        self.repo.object_store.add_object(head_tree)
+
+        head_commit = Commit()
+        head_commit.tree = head_tree.id
+        head_commit.parents = [base_commit.id]
+        head_commit.author = b"Test <test@example.com>"
+        head_commit.committer = b"Test <test@example.com>"
+        head_commit.message = b"Head commit"
+        head_commit.commit_time = head_commit.author_time = 12346
+        head_commit.commit_timezone = head_commit.author_timezone = 0
+        self.repo.object_store.add_object(head_commit)
+
+        # Create branch1 commit (modifies file2)
+        branch1_tree = Tree()
+        branch1_blob2 = Blob.from_string(b"file2 modified by branch1\n")
+        self.repo.object_store.add_object(branch1_blob2)
+        branch1_tree.add(b"file1.txt", 0o100644, blob1.id)
+        branch1_tree.add(b"file2.txt", 0o100644, branch1_blob2.id)
+        branch1_tree.add(b"file3.txt", 0o100644, blob3.id)
+        self.repo.object_store.add_object(branch1_tree)
+
+        branch1_commit = Commit()
+        branch1_commit.tree = branch1_tree.id
+        branch1_commit.parents = [base_commit.id]
+        branch1_commit.author = b"Test <test@example.com>"
+        branch1_commit.committer = b"Test <test@example.com>"
+        branch1_commit.message = b"Branch1 commit"
+        branch1_commit.commit_time = branch1_commit.author_time = 12347
+        branch1_commit.commit_timezone = branch1_commit.author_timezone = 0
+        self.repo.object_store.add_object(branch1_commit)
+
+        # Create branch2 commit (modifies file3)
+        branch2_tree = Tree()
+        branch2_blob3 = Blob.from_string(b"file3 modified by branch2\n")
+        self.repo.object_store.add_object(branch2_blob3)
+        branch2_tree.add(b"file1.txt", 0o100644, blob1.id)
+        branch2_tree.add(b"file2.txt", 0o100644, blob2.id)
+        branch2_tree.add(b"file3.txt", 0o100644, branch2_blob3.id)
+        self.repo.object_store.add_object(branch2_tree)
+
+        branch2_commit = Commit()
+        branch2_commit.tree = branch2_tree.id
+        branch2_commit.parents = [base_commit.id]
+        branch2_commit.author = b"Test <test@example.com>"
+        branch2_commit.committer = b"Test <test@example.com>"
+        branch2_commit.message = b"Branch2 commit"
+        branch2_commit.commit_time = branch2_commit.author_time = 12348
+        branch2_commit.commit_timezone = branch2_commit.author_timezone = 0
+        self.repo.object_store.add_object(branch2_commit)
+
+        # Perform octopus merge
+        merged_tree, conflicts = octopus_merge(
+            self.repo.object_store,
+            [base_commit.id],
+            head_commit,
+            [branch1_commit, branch2_commit],
+        )
+
+        # Should have no conflicts since each branch modified different files
+        self.assertEqual(len(conflicts), 0)
+
+        # Check that all three modifications are in the merged tree
+        self.assertIn(b"file1.txt", [item.path for item in merged_tree.items()])
+        self.assertIn(b"file2.txt", [item.path for item in merged_tree.items()])
+        self.assertIn(b"file3.txt", [item.path for item in merged_tree.items()])
+
+    def test_octopus_merge_with_conflict(self):
+        """Test that octopus merge refuses to proceed with conflicts."""
+        from dulwich.merge import octopus_merge
+
+        # Create base commit
+        base_tree = Tree()
+        blob1 = Blob.from_string(b"original content\n")
+        self.repo.object_store.add_object(blob1)
+        base_tree.add(b"file.txt", 0o100644, blob1.id)
+        self.repo.object_store.add_object(base_tree)
+
+        base_commit = Commit()
+        base_commit.tree = base_tree.id
+        base_commit.author = b"Test <test@example.com>"
+        base_commit.committer = b"Test <test@example.com>"
+        base_commit.message = b"Base commit"
+        base_commit.commit_time = base_commit.author_time = 12345
+        base_commit.commit_timezone = base_commit.author_timezone = 0
+        self.repo.object_store.add_object(base_commit)
+
+        # Create HEAD commit
+        head_tree = Tree()
+        head_blob = Blob.from_string(b"head content\n")
+        self.repo.object_store.add_object(head_blob)
+        head_tree.add(b"file.txt", 0o100644, head_blob.id)
+        self.repo.object_store.add_object(head_tree)
+
+        head_commit = Commit()
+        head_commit.tree = head_tree.id
+        head_commit.parents = [base_commit.id]
+        head_commit.author = b"Test <test@example.com>"
+        head_commit.committer = b"Test <test@example.com>"
+        head_commit.message = b"Head commit"
+        head_commit.commit_time = head_commit.author_time = 12346
+        head_commit.commit_timezone = head_commit.author_timezone = 0
+        self.repo.object_store.add_object(head_commit)
+
+        # Create branch1 commit (conflicts with head)
+        branch1_tree = Tree()
+        branch1_blob = Blob.from_string(b"branch1 content\n")
+        self.repo.object_store.add_object(branch1_blob)
+        branch1_tree.add(b"file.txt", 0o100644, branch1_blob.id)
+        self.repo.object_store.add_object(branch1_tree)
+
+        branch1_commit = Commit()
+        branch1_commit.tree = branch1_tree.id
+        branch1_commit.parents = [base_commit.id]
+        branch1_commit.author = b"Test <test@example.com>"
+        branch1_commit.committer = b"Test <test@example.com>"
+        branch1_commit.message = b"Branch1 commit"
+        branch1_commit.commit_time = branch1_commit.author_time = 12347
+        branch1_commit.commit_timezone = branch1_commit.author_timezone = 0
+        self.repo.object_store.add_object(branch1_commit)
+
+        # Perform octopus merge
+        _merged_tree, conflicts = octopus_merge(
+            self.repo.object_store,
+            [base_commit.id],
+            head_commit,
+            [branch1_commit],
+        )
+
+        # Should have conflicts and refuse to merge
+        self.assertEqual(len(conflicts), 1)
+        self.assertEqual(conflicts[0], b"file.txt")
+
+    def test_octopus_merge_no_commits(self):
+        """Test that octopus merge raises error with no commits to merge."""
+        from dulwich.merge import octopus_merge
+
+        # Create a simple commit
+        tree = Tree()
+        blob = Blob.from_string(b"content\n")
+        self.repo.object_store.add_object(blob)
+        tree.add(b"file.txt", 0o100644, blob.id)
+        self.repo.object_store.add_object(tree)
+
+        commit = Commit()
+        commit.tree = tree.id
+        commit.author = b"Test <test@example.com>"
+        commit.committer = b"Test <test@example.com>"
+        commit.message = b"Commit"
+        commit.commit_time = commit.author_time = 12345
+        commit.commit_timezone = commit.author_timezone = 0
+        self.repo.object_store.add_object(commit)
+
+        # Try to do octopus merge with no commits
+        with self.assertRaises(ValueError):
+            octopus_merge(
+                self.repo.object_store,
+                [commit.id],
+                commit,
+                [],
+            )

+ 195 - 0
tests/test_porcelain_merge.py

@@ -273,6 +273,201 @@ class PorcelainMergeTests(TestCase):
             # Try to merge nonexistent commit
             # Try to merge nonexistent commit
             self.assertRaises(porcelain.Error, porcelain.merge, tmpdir, "nonexistent")
             self.assertRaises(porcelain.Error, porcelain.merge, tmpdir, "nonexistent")
 
 
+    def test_octopus_merge_three_branches(self):
+        """Test octopus merge with three branches."""
+        with tempfile.TemporaryDirectory() as tmpdir:
+            # Initialize repo
+            porcelain.init(tmpdir)
+
+            # Create initial commit with three files
+            with open(os.path.join(tmpdir, "file1.txt"), "w") as f:
+                f.write("File 1 content\n")
+            with open(os.path.join(tmpdir, "file2.txt"), "w") as f:
+                f.write("File 2 content\n")
+            with open(os.path.join(tmpdir, "file3.txt"), "w") as f:
+                f.write("File 3 content\n")
+            porcelain.add(tmpdir, paths=["file1.txt", "file2.txt", "file3.txt"])
+            porcelain.commit(tmpdir, message=b"Initial commit")
+
+            # Create branch1 and modify file1
+            porcelain.branch_create(tmpdir, "branch1")
+            porcelain.checkout(tmpdir, "branch1")
+            with open(os.path.join(tmpdir, "file1.txt"), "w") as f:
+                f.write("Branch1 modified file1\n")
+            porcelain.add(tmpdir, paths=["file1.txt"])
+            porcelain.commit(tmpdir, message=b"Branch1 modifies file1")
+
+            # Create branch2 and modify file2
+            porcelain.checkout(tmpdir, "master")
+            porcelain.branch_create(tmpdir, "branch2")
+            porcelain.checkout(tmpdir, "branch2")
+            with open(os.path.join(tmpdir, "file2.txt"), "w") as f:
+                f.write("Branch2 modified file2\n")
+            porcelain.add(tmpdir, paths=["file2.txt"])
+            porcelain.commit(tmpdir, message=b"Branch2 modifies file2")
+
+            # Create branch3 and modify file3
+            porcelain.checkout(tmpdir, "master")
+            porcelain.branch_create(tmpdir, "branch3")
+            porcelain.checkout(tmpdir, "branch3")
+            with open(os.path.join(tmpdir, "file3.txt"), "w") as f:
+                f.write("Branch3 modified file3\n")
+            porcelain.add(tmpdir, paths=["file3.txt"])
+            porcelain.commit(tmpdir, message=b"Branch3 modifies file3")
+
+            # Go back to master and octopus merge all three branches
+            porcelain.checkout(tmpdir, "master")
+            merge_commit, conflicts = porcelain.merge(
+                tmpdir, ["branch1", "branch2", "branch3"]
+            )
+
+            # Should succeed with no conflicts
+            self.assertIsNotNone(merge_commit)
+            self.assertEqual(conflicts, [])
+
+            # Check that the merge commit has 4 parents (master + 3 branches)
+            with Repo(tmpdir) as repo:
+                commit = repo[merge_commit]
+                self.assertEqual(len(commit.parents), 4)
+
+            # Check that all modifications are present
+            with open(os.path.join(tmpdir, "file1.txt")) as f:
+                self.assertEqual(f.read(), "Branch1 modified file1\n")
+            with open(os.path.join(tmpdir, "file2.txt")) as f:
+                self.assertEqual(f.read(), "Branch2 modified file2\n")
+            with open(os.path.join(tmpdir, "file3.txt")) as f:
+                self.assertEqual(f.read(), "Branch3 modified file3\n")
+
+    def test_octopus_merge_with_conflicts(self):
+        """Test that octopus merge refuses to proceed with conflicts."""
+        with tempfile.TemporaryDirectory() as tmpdir:
+            # Initialize repo
+            porcelain.init(tmpdir)
+
+            # Create initial commit
+            with open(os.path.join(tmpdir, "file1.txt"), "w") as f:
+                f.write("Initial content\n")
+            porcelain.add(tmpdir, paths=["file1.txt"])
+            porcelain.commit(tmpdir, message=b"Initial commit")
+
+            # Create branch1 and modify file1
+            porcelain.branch_create(tmpdir, "branch1")
+            porcelain.checkout(tmpdir, "branch1")
+            with open(os.path.join(tmpdir, "file1.txt"), "w") as f:
+                f.write("Branch1 content\n")
+            porcelain.add(tmpdir, paths=["file1.txt"])
+            porcelain.commit(tmpdir, message=b"Branch1 modifies file1")
+
+            # Create branch2 and modify file1 differently
+            porcelain.checkout(tmpdir, "master")
+            porcelain.branch_create(tmpdir, "branch2")
+            porcelain.checkout(tmpdir, "branch2")
+            with open(os.path.join(tmpdir, "file1.txt"), "w") as f:
+                f.write("Branch2 content\n")
+            porcelain.add(tmpdir, paths=["file1.txt"])
+            porcelain.commit(tmpdir, message=b"Branch2 modifies file1")
+
+            # Go back to master and try octopus merge - should fail
+            porcelain.checkout(tmpdir, "master")
+            merge_commit, conflicts = porcelain.merge(tmpdir, ["branch1", "branch2"])
+
+            # Should have conflicts and no merge commit
+            self.assertIsNone(merge_commit)
+            self.assertEqual(len(conflicts), 1)
+            self.assertEqual(conflicts[0], b"file1.txt")
+
+    def test_octopus_merge_no_commit_flag(self):
+        """Test octopus merge with no_commit flag."""
+        with tempfile.TemporaryDirectory() as tmpdir:
+            # Initialize repo
+            porcelain.init(tmpdir)
+
+            # Create initial commit
+            with open(os.path.join(tmpdir, "file1.txt"), "w") as f:
+                f.write("File 1 content\n")
+            with open(os.path.join(tmpdir, "file2.txt"), "w") as f:
+                f.write("File 2 content\n")
+            porcelain.add(tmpdir, paths=["file1.txt", "file2.txt"])
+            master_commit = porcelain.commit(tmpdir, message=b"Initial commit")
+
+            # Create branch1 and modify file1
+            porcelain.branch_create(tmpdir, "branch1")
+            porcelain.checkout(tmpdir, "branch1")
+            with open(os.path.join(tmpdir, "file1.txt"), "w") as f:
+                f.write("Branch1 modified file1\n")
+            porcelain.add(tmpdir, paths=["file1.txt"])
+            porcelain.commit(tmpdir, message=b"Branch1 modifies file1")
+
+            # Create branch2 and modify file2
+            porcelain.checkout(tmpdir, "master")
+            porcelain.branch_create(tmpdir, "branch2")
+            porcelain.checkout(tmpdir, "branch2")
+            with open(os.path.join(tmpdir, "file2.txt"), "w") as f:
+                f.write("Branch2 modified file2\n")
+            porcelain.add(tmpdir, paths=["file2.txt"])
+            porcelain.commit(tmpdir, message=b"Branch2 modifies file2")
+
+            # Go back to master and octopus merge with no_commit
+            porcelain.checkout(tmpdir, "master")
+            merge_commit, conflicts = porcelain.merge(
+                tmpdir, ["branch1", "branch2"], no_commit=True
+            )
+
+            # Should not create commit
+            self.assertIsNone(merge_commit)
+            self.assertEqual(conflicts, [])
+
+            # Check that files are merged but no commit was created
+            with open(os.path.join(tmpdir, "file1.txt")) as f:
+                self.assertEqual(f.read(), "Branch1 modified file1\n")
+            with open(os.path.join(tmpdir, "file2.txt")) as f:
+                self.assertEqual(f.read(), "Branch2 modified file2\n")
+
+            # HEAD should still point to master_commit
+            with Repo(tmpdir) as repo:
+                self.assertEqual(repo.refs[b"HEAD"], master_commit)
+
+    def test_octopus_merge_single_branch(self):
+        """Test that octopus merge with single branch falls back to regular merge."""
+        with tempfile.TemporaryDirectory() as tmpdir:
+            # Initialize repo
+            porcelain.init(tmpdir)
+
+            # Create initial commit
+            with open(os.path.join(tmpdir, "file1.txt"), "w") as f:
+                f.write("Initial content\n")
+            with open(os.path.join(tmpdir, "file2.txt"), "w") as f:
+                f.write("Initial file2\n")
+            porcelain.add(tmpdir, paths=["file1.txt", "file2.txt"])
+            porcelain.commit(tmpdir, message=b"Initial commit")
+
+            # Create branch and modify file1
+            porcelain.branch_create(tmpdir, "branch1")
+            porcelain.checkout(tmpdir, "branch1")
+            with open(os.path.join(tmpdir, "file1.txt"), "w") as f:
+                f.write("Branch1 content\n")
+            porcelain.add(tmpdir, paths=["file1.txt"])
+            porcelain.commit(tmpdir, message=b"Branch1 changes")
+
+            # Go back to master and modify file2 to prevent fast-forward
+            porcelain.checkout(tmpdir, "master")
+            with open(os.path.join(tmpdir, "file2.txt"), "w") as f:
+                f.write("Master file2\n")
+            porcelain.add(tmpdir, paths=["file2.txt"])
+            porcelain.commit(tmpdir, message=b"Master changes")
+
+            # Merge with list containing one branch
+            merge_commit, conflicts = porcelain.merge(tmpdir, ["branch1"])
+
+            # Should create a regular merge commit
+            self.assertIsNotNone(merge_commit)
+            self.assertEqual(conflicts, [])
+
+            # Check the merge commit (should have 2 parents for regular merge)
+            with Repo(tmpdir) as repo:
+                commit = repo[merge_commit]
+                self.assertEqual(len(commit.parents), 2)
+
 
 
 class PorcelainMergeTreeTests(TestCase):
 class PorcelainMergeTreeTests(TestCase):
     """Tests for the porcelain merge_tree functionality."""
     """Tests for the porcelain merge_tree functionality."""