Pārlūkot izejas kodu

Merge branch 'master' into recursive-merge

Jelmer Vernooij 3 mēneši atpakaļ
vecāks
revīzija
bcf237d786
7 mainītis faili ar 852 papildinājumiem un 22 dzēšanām
  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
 
+ * 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.
    Compares commits by patch ID to identify equivalent patches regardless of
    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
         """
         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(
             "--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)
 
         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(
                 ".",
-                parsed_args.commit,
+                committish,
                 no_commit=parsed_args.no_commit,
                 no_ff=parsed_args.no_ff,
                 message=parsed_args.message,
@@ -3382,9 +3389,14 @@ class cmd_merge(Command):
                 logger.warning("Merge conflicts in %d file(s):", len(conflicts))
                 for conflict_path in conflicts:
                     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
             elif merge_commit_id is None and not parsed_args.no_commit:
                 logger.info("Already up to date.")
@@ -3392,10 +3404,16 @@ class cmd_merge(Command):
                 logger.info("Automatic merge successful; not committing as requested.")
             else:
                 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
         except porcelain.Error as 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(theirs_tree, 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, [])
 
 
+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(
     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_ff: bool = False,
     message: Optional[bytes] = None,
     author: Optional[bytes] = None,
     committer: Optional[bytes] = None,
 ) -> tuple[Optional[bytes], list[bytes]]:
-    """Merge a commit into the current branch.
+    """Merge one or more commits into the current branch.
 
     Args:
       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_ff: If True, force creation of a merge commit
       message: Optional merge commit message
@@ -5458,17 +5593,42 @@ def merge(
       Error: If there is no HEAD reference or commit cannot be found
     """
     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
         from .gc import maybe_auto_gc

+ 159 - 0
tests/test_cli_merge.py

@@ -268,6 +268,165 @@ class CLIMergeTests(TestCase):
             finally:
                 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__":
     unittest.main()

+ 204 - 0
tests/test_merge.py

@@ -742,3 +742,207 @@ class RecursiveMergeTests(unittest.TestCase):
         self.assertEqual(len(conflicts), 0)
         # Merged tree should be empty
         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
             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):
     """Tests for the porcelain merge_tree functionality."""