فهرست منبع

Implement basic rebase (#1595)

Jelmer Vernooij 1 ماه پیش
والد
کامیت
739d7d474f
5فایلهای تغییر یافته به همراه1198 افزوده شده و 0 حذف شده
  1. 70 0
      dulwich/cli.py
  2. 79 0
      dulwich/porcelain.py
  3. 565 0
      dulwich/rebase.py
  4. 28 0
      dulwich/repo.py
  5. 456 0
      tests/test_rebase.py

+ 70 - 0
dulwich/cli.py

@@ -1168,6 +1168,75 @@ class cmd_count_objects(Command):
             print(f"{stats.count} objects, {stats.size // 1024} kilobytes")
 
 
+class cmd_rebase(Command):
+    def run(self, args) -> int:
+        parser = argparse.ArgumentParser()
+        parser.add_argument(
+            "upstream", nargs="?", help="Upstream branch to rebase onto"
+        )
+        parser.add_argument("--onto", type=str, help="Rebase onto specific commit")
+        parser.add_argument(
+            "--branch", type=str, help="Branch to rebase (default: current)"
+        )
+        parser.add_argument(
+            "--abort", action="store_true", help="Abort an in-progress rebase"
+        )
+        parser.add_argument(
+            "--continue",
+            dest="continue_rebase",
+            action="store_true",
+            help="Continue an in-progress rebase",
+        )
+        parser.add_argument(
+            "--skip", action="store_true", help="Skip current commit and continue"
+        )
+        args = parser.parse_args(args)
+
+        # Handle abort/continue/skip first
+        if args.abort:
+            try:
+                porcelain.rebase(".", args.upstream or "HEAD", abort=True)
+                print("Rebase aborted.")
+            except porcelain.Error as e:
+                print(f"Error: {e}")
+                return 1
+            return 0
+
+        if args.continue_rebase:
+            try:
+                new_shas = porcelain.rebase(
+                    ".", args.upstream or "HEAD", continue_rebase=True
+                )
+                print("Rebase complete.")
+            except porcelain.Error as e:
+                print(f"Error: {e}")
+                return 1
+            return 0
+
+        # Normal rebase requires upstream
+        if not args.upstream:
+            print("Error: Missing required argument 'upstream'")
+            return 1
+
+        try:
+            new_shas = porcelain.rebase(
+                ".",
+                args.upstream,
+                onto=args.onto,
+                branch=args.branch,
+            )
+
+            if new_shas:
+                print(f"Successfully rebased {len(new_shas)} commits.")
+            else:
+                print("Already up to date.")
+            return 0
+
+        except porcelain.Error as e:
+            print(f"Error: {e}")
+            return 1
+
+
 class cmd_help(Command):
     def run(self, args) -> None:
         parser = argparse.ArgumentParser()
@@ -1228,6 +1297,7 @@ commands = {
     "pack-refs": cmd_pack_refs,
     "pull": cmd_pull,
     "push": cmd_push,
+    "rebase": cmd_rebase,
     "receive-pack": cmd_receive_pack,
     "remote": cmd_remote,
     "repack": cmd_repack,

+ 79 - 0
dulwich/porcelain.py

@@ -3010,3 +3010,82 @@ def count_objects(repo=".", verbose=False) -> CountObjectsResult:
             packs=pack_count,
             size_pack=pack_size,
         )
+
+
+def rebase(
+    repo: Union[Repo, str],
+    upstream: Union[bytes, str],
+    onto: Optional[Union[bytes, str]] = None,
+    branch: Optional[Union[bytes, str]] = None,
+    abort: bool = False,
+    continue_rebase: bool = False,
+    skip: bool = False,
+) -> list[bytes]:
+    """Rebase commits onto another branch.
+
+    Args:
+      repo: Repository to rebase in
+      upstream: Upstream branch/commit to rebase onto
+      onto: Specific commit to rebase onto (defaults to upstream)
+      branch: Branch to rebase (defaults to current branch)
+      abort: Abort an in-progress rebase
+      continue_rebase: Continue an in-progress rebase
+      skip: Skip current commit and continue rebase
+
+    Returns:
+      List of new commit SHAs created by rebase
+
+    Raises:
+      Error: If rebase fails or conflicts occur
+    """
+    from .rebase import RebaseConflict, RebaseError, Rebaser
+
+    with open_repo_closing(repo) as r:
+        rebaser = Rebaser(r)
+
+        if abort:
+            try:
+                rebaser.abort()
+                return []
+            except RebaseError as e:
+                raise Error(str(e))
+
+        if continue_rebase:
+            try:
+                result = rebaser.continue_()
+                if result is None:
+                    # Rebase complete
+                    return []
+                elif isinstance(result, tuple) and result[1]:
+                    # Still have conflicts
+                    raise Error(
+                        f"Conflicts in: {', '.join(f.decode('utf-8', 'replace') for f in result[1])}"
+                    )
+            except RebaseError as e:
+                raise Error(str(e))
+
+        # Convert string refs to bytes
+        if isinstance(upstream, str):
+            upstream = upstream.encode("utf-8")
+        if isinstance(onto, str):
+            onto = onto.encode("utf-8") if onto else None
+        if isinstance(branch, str):
+            branch = branch.encode("utf-8") if branch else None
+
+        try:
+            # Start rebase
+            rebaser.start(upstream, onto, branch)
+
+            # Continue rebase automatically
+            result = rebaser.continue_()
+            if result is not None:
+                # Conflicts
+                raise RebaseConflict(result[1])
+
+            # Return the SHAs of the rebased commits
+            return [c.id for c in rebaser._done]
+
+        except RebaseConflict as e:
+            raise Error(str(e))
+        except RebaseError as e:
+            raise Error(str(e))

+ 565 - 0
dulwich/rebase.py

@@ -0,0 +1,565 @@
+# rebase.py -- Git rebase implementation
+# Copyright (C) 2025 Dulwich contributors
+#
+# SPDX-License-Identifier: Apache-2.0 OR GPL-2.0-or-later
+# Dulwich is dual-licensed under the Apache License, Version 2.0 and the GNU
+# General Public License as public by the Free Software Foundation; version 2.0
+# or (at your option) any later version. You can redistribute it and/or
+# modify it under the terms of either of these two licenses.
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# You should have received a copy of the licenses; if not, see
+# <http://www.gnu.org/licenses/> for a copy of the GNU General Public License
+# and <http://www.apache.org/licenses/LICENSE-2.0> for a copy of the Apache
+# License, Version 2.0.
+#
+
+"""Git rebase implementation."""
+
+from typing import Optional, Protocol
+
+from dulwich.graph import find_merge_base
+from dulwich.merge import three_way_merge
+from dulwich.objects import Commit
+from dulwich.objectspec import parse_commit
+from dulwich.repo import Repo
+
+
+class RebaseError(Exception):
+    """Base class for rebase errors."""
+
+
+class RebaseConflict(RebaseError):
+    """Raised when a rebase conflict occurs."""
+
+    def __init__(self, conflicted_files: list[bytes]):
+        self.conflicted_files = conflicted_files
+        super().__init__(
+            f"Conflicts in: {', '.join(f.decode('utf-8', 'replace') for f in conflicted_files)}"
+        )
+
+
+class RebaseAbort(RebaseError):
+    """Raised when rebase is aborted."""
+
+
+class RebaseStateManager(Protocol):
+    """Protocol for managing rebase state."""
+
+    def save(
+        self,
+        original_head: Optional[bytes],
+        rebasing_branch: Optional[bytes],
+        onto: Optional[bytes],
+        todo: list[Commit],
+        done: list[Commit],
+    ) -> None:
+        """Save rebase state."""
+        ...
+
+    def load(
+        self,
+    ) -> tuple[
+        Optional[bytes],  # original_head
+        Optional[bytes],  # rebasing_branch
+        Optional[bytes],  # onto
+        list[Commit],  # todo
+        list[Commit],  # done
+    ]:
+        """Load rebase state."""
+        ...
+
+    def clean(self) -> None:
+        """Clean up rebase state."""
+        ...
+
+    def exists(self) -> bool:
+        """Check if rebase state exists."""
+        ...
+
+
+class DiskRebaseStateManager:
+    """Manages rebase state on disk using same files as C Git."""
+
+    def __init__(self, path: str) -> None:
+        """Initialize disk rebase state manager.
+
+        Args:
+            path: Path to the rebase-merge directory
+        """
+        self.path = path
+
+    def save(
+        self,
+        original_head: Optional[bytes],
+        rebasing_branch: Optional[bytes],
+        onto: Optional[bytes],
+        todo: list[Commit],
+        done: list[Commit],
+    ) -> None:
+        """Save rebase state to disk."""
+        import os
+
+        # Ensure the directory exists
+        os.makedirs(self.path, exist_ok=True)
+
+        # Store the original HEAD ref (e.g. "refs/heads/feature")
+        if original_head:
+            self._write_file("orig-head", original_head)
+
+        # Store the branch name being rebased
+        if rebasing_branch:
+            self._write_file("head-name", rebasing_branch)
+
+        # Store the commit we're rebasing onto
+        if onto:
+            self._write_file("onto", onto)
+
+        # Track progress
+        if todo:
+            # Store the current commit being rebased (same as C Git)
+            current_commit = todo[0]
+            self._write_file("stopped-sha", current_commit.id)
+
+            # Store progress counters
+            msgnum = len(done) + 1  # Current commit number (1-based)
+            end = len(done) + len(todo)  # Total number of commits
+            self._write_file("msgnum", str(msgnum).encode())
+            self._write_file("end", str(end).encode())
+
+        # TODO: Add support for writing git-rebase-todo for interactive rebase
+
+    def _write_file(self, name: str, content: bytes) -> None:
+        """Write content to a file in the rebase directory."""
+        import os
+
+        with open(os.path.join(self.path, name), "wb") as f:
+            f.write(content)
+
+    def load(
+        self,
+    ) -> tuple[
+        Optional[bytes],
+        Optional[bytes],
+        Optional[bytes],
+        list[Commit],
+        list[Commit],
+    ]:
+        """Load rebase state from disk."""
+        original_head = None
+        rebasing_branch = None
+        onto = None
+        todo: list[Commit] = []
+        done: list[Commit] = []
+
+        # Load rebase state files
+        original_head = self._read_file("orig-head")
+        rebasing_branch = self._read_file("head-name")
+        onto = self._read_file("onto")
+
+        # TODO: Load todo list and done list for resuming rebase
+
+        return original_head, rebasing_branch, onto, todo, done
+
+    def _read_file(self, name: str) -> Optional[bytes]:
+        """Read content from a file in the rebase directory."""
+        import os
+
+        try:
+            with open(os.path.join(self.path, name), "rb") as f:
+                return f.read().strip()
+        except FileNotFoundError:
+            return None
+
+    def clean(self) -> None:
+        """Clean up rebase state files."""
+        import shutil
+
+        try:
+            shutil.rmtree(self.path)
+        except FileNotFoundError:
+            # Directory doesn't exist, that's ok
+            pass
+
+    def exists(self) -> bool:
+        """Check if rebase state exists."""
+        import os
+
+        return os.path.exists(os.path.join(self.path, "orig-head"))
+
+
+class MemoryRebaseStateManager:
+    """Manages rebase state in memory for MemoryRepo."""
+
+    def __init__(self, repo: Repo) -> None:
+        self.repo = repo
+        self._state: Optional[dict] = None
+
+    def save(
+        self,
+        original_head: Optional[bytes],
+        rebasing_branch: Optional[bytes],
+        onto: Optional[bytes],
+        todo: list[Commit],
+        done: list[Commit],
+    ) -> None:
+        """Save rebase state in memory."""
+        self._state = {
+            "original_head": original_head,
+            "rebasing_branch": rebasing_branch,
+            "onto": onto,
+            "todo": todo[:],  # Copy the lists
+            "done": done[:],
+        }
+
+    def load(
+        self,
+    ) -> tuple[
+        Optional[bytes],
+        Optional[bytes],
+        Optional[bytes],
+        list[Commit],
+        list[Commit],
+    ]:
+        """Load rebase state from memory."""
+        if self._state is None:
+            return None, None, None, [], []
+
+        return (
+            self._state["original_head"],
+            self._state["rebasing_branch"],
+            self._state["onto"],
+            self._state["todo"][:],  # Return copies
+            self._state["done"][:],
+        )
+
+    def clean(self) -> None:
+        """Clean up rebase state."""
+        self._state = None
+
+    def exists(self) -> bool:
+        """Check if rebase state exists."""
+        return self._state is not None
+
+
+class Rebaser:
+    """Handles git rebase operations."""
+
+    def __init__(self, repo: Repo):
+        """Initialize rebaser.
+
+        Args:
+            repo: Repository to perform rebase in
+        """
+        self.repo = repo
+        self.object_store = repo.object_store
+        self._state_manager = repo.get_rebase_state_manager()
+
+        # Initialize state
+        self._original_head: Optional[bytes] = None
+        self._onto = None
+        self._todo: list[Commit] = []
+        self._done: list[Commit] = []
+        self._rebasing_branch: Optional[bytes] = None
+
+        # Load any existing rebase state
+        self._load_rebase_state()
+
+    def _get_commits_to_rebase(
+        self, upstream: bytes, branch: Optional[bytes] = None
+    ) -> list[Commit]:
+        """Get list of commits to rebase.
+
+        Args:
+            upstream: Upstream commit/branch to rebase onto
+            branch: Branch to rebase (defaults to current branch)
+
+        Returns:
+            List of commits to rebase in chronological order
+        """
+        # Get the branch commit
+        if branch is None:
+            # Use current HEAD
+            head_ref, head_sha = self.repo.refs.follow(b"HEAD")
+            branch_commit = self.repo[head_sha]
+        else:
+            # Parse the branch reference
+            branch_commit = parse_commit(self.repo, branch)
+
+        # Get upstream commit
+        upstream_commit = parse_commit(self.repo, upstream)
+
+        # If already up to date, return empty list
+        if branch_commit.id == upstream_commit.id:
+            return []
+
+        merge_bases = find_merge_base(self.repo, [branch_commit.id, upstream_commit.id])
+        if not merge_bases:
+            raise RebaseError("No common ancestor found")
+
+        merge_base = merge_bases[0]
+
+        # Get commits between merge base and branch head
+        commits = []
+        current = branch_commit
+        while current.id != merge_base:
+            commits.append(current)
+            if not current.parents:
+                break
+            current = self.repo[current.parents[0]]
+
+        # Return in chronological order (oldest first)
+        return list(reversed(commits))
+
+    def _cherry_pick(self, commit: Commit, onto: bytes) -> tuple[bytes, list[bytes]]:
+        """Cherry-pick a commit onto another commit.
+
+        Args:
+            commit: Commit to cherry-pick
+            onto: SHA of commit to cherry-pick onto
+
+        Returns:
+            Tuple of (new_commit_sha, list_of_conflicted_files)
+        """
+        # Get the parent of the commit being cherry-picked
+        if not commit.parents:
+            raise RebaseError(f"Cannot cherry-pick root commit {commit.id}")
+
+        parent = self.repo[commit.parents[0]]
+        onto_commit = self.repo[onto]
+
+        # Perform three-way merge
+        merged_tree, conflicts = three_way_merge(
+            self.object_store, parent, onto_commit, commit
+        )
+
+        if conflicts:
+            # Store merge state for conflict resolution
+            self.repo._put_named_file("rebase-merge/stopped-sha", commit.id)
+            return commit.id, conflicts
+
+        # Create new commit
+        new_commit = Commit()
+        new_commit.tree = merged_tree.id
+        new_commit.parents = [onto]
+        new_commit.author = commit.author
+        new_commit.author_time = commit.author_time
+        new_commit.author_timezone = commit.author_timezone
+        new_commit.committer = commit.committer
+        new_commit.commit_time = commit.commit_time
+        new_commit.commit_timezone = commit.commit_timezone
+        new_commit.message = commit.message
+        new_commit.encoding = commit.encoding
+
+        self.object_store.add_object(merged_tree)
+        self.object_store.add_object(new_commit)
+
+        return new_commit.id, []
+
+    def start(
+        self,
+        upstream: bytes,
+        onto: Optional[bytes] = None,
+        branch: Optional[bytes] = None,
+    ) -> list[Commit]:
+        """Start a rebase.
+
+        Args:
+            upstream: Upstream branch/commit to rebase onto
+            onto: Specific commit to rebase onto (defaults to upstream)
+            branch: Branch to rebase (defaults to current branch)
+
+        Returns:
+            List of commits that will be rebased
+        """
+        # Save original HEAD
+        self._original_head = self.repo.refs.read_ref(b"HEAD")
+
+        # Save which branch we're rebasing (for later update)
+        if branch is not None:
+            # Parse the branch ref
+            if branch.startswith(b"refs/heads/"):
+                self._rebasing_branch = branch
+            else:
+                # Assume it's a branch name
+                self._rebasing_branch = b"refs/heads/" + branch
+        else:
+            # Use current branch
+            if self._original_head is not None and self._original_head.startswith(
+                b"ref: "
+            ):
+                self._rebasing_branch = self._original_head[5:]
+            else:
+                self._rebasing_branch = None
+
+        # Determine onto commit
+        if onto is None:
+            onto = upstream
+        # Parse the onto commit
+        onto_commit = parse_commit(self.repo, onto)
+        self._onto = onto_commit.id
+
+        # Get commits to rebase
+        commits = self._get_commits_to_rebase(upstream, branch)
+        self._todo = commits
+        self._done = []
+
+        # Store rebase state
+        self._save_rebase_state()
+
+        return commits
+
+    def continue_(self) -> Optional[tuple[bytes, list[bytes]]]:
+        """Continue an in-progress rebase.
+
+        Returns:
+            None if rebase is complete, or tuple of (commit_sha, conflicts) for next commit
+        """
+        if not self._todo:
+            self._finish_rebase()
+            return None
+
+        # Get next commit to rebase
+        commit = self._todo.pop(0)
+
+        # Determine what to rebase onto
+        if self._done:
+            onto = self._done[-1].id
+        else:
+            onto = self._onto
+
+        # Cherry-pick the commit
+        new_sha, conflicts = self._cherry_pick(commit, onto)
+
+        if new_sha:
+            # Success - add to done list
+            self._done.append(self.repo[new_sha])
+            self._save_rebase_state()
+
+            # Continue with next commit if any
+            if self._todo:
+                return self.continue_()
+            else:
+                self._finish_rebase()
+                return None
+        else:
+            # Conflicts - save state and return
+            self._save_rebase_state()
+            return (commit.id, conflicts)
+
+    def is_in_progress(self) -> bool:
+        """Check if a rebase is currently in progress."""
+        return self._state_manager.exists()
+
+    def abort(self) -> None:
+        """Abort an in-progress rebase and restore original state."""
+        if not self.is_in_progress():
+            raise RebaseError("No rebase in progress")
+
+        # Restore original HEAD
+        self.repo.refs[b"HEAD"] = self._original_head
+
+        # Clean up rebase state
+        self._clean_rebase_state()
+
+        # Reset instance state
+        self._original_head = None
+        self._onto = None
+        self._todo = []
+        self._done = []
+
+    def _finish_rebase(self) -> None:
+        """Finish rebase by updating HEAD and cleaning up."""
+        if not self._done:
+            # No commits were rebased
+            return
+
+        # Update HEAD to point to last rebased commit
+        last_commit = self._done[-1]
+
+        # Update the branch we're rebasing
+        if hasattr(self, "_rebasing_branch") and self._rebasing_branch:
+            self.repo.refs[self._rebasing_branch] = last_commit.id
+            # If HEAD was pointing to this branch, it will follow automatically
+        else:
+            # If we don't know which branch, check current HEAD
+            head_ref = self.repo.refs[b"HEAD"]
+            if head_ref.startswith(b"ref: "):
+                branch_ref = head_ref[5:]
+                self.repo.refs[branch_ref] = last_commit.id
+            else:
+                # Detached HEAD
+                self.repo.refs[b"HEAD"] = last_commit.id
+
+        # Clean up rebase state
+        self._clean_rebase_state()
+
+        # Reset instance state but keep _done for caller
+        self._original_head = None
+        self._onto = None
+        self._todo = []
+
+    def _save_rebase_state(self) -> None:
+        """Save rebase state to allow resuming."""
+        self._state_manager.save(
+            self._original_head,
+            self._rebasing_branch,
+            self._onto,
+            self._todo,
+            self._done,
+        )
+
+    def _load_rebase_state(self) -> None:
+        """Load existing rebase state if present."""
+        (
+            self._original_head,
+            self._rebasing_branch,
+            self._onto,
+            self._todo,
+            self._done,
+        ) = self._state_manager.load()
+
+    def _clean_rebase_state(self) -> None:
+        """Clean up rebase state files."""
+        self._state_manager.clean()
+
+
+def rebase(
+    repo: Repo,
+    upstream: bytes,
+    onto: Optional[bytes] = None,
+    branch: Optional[bytes] = None,
+) -> list[bytes]:
+    """Perform a git rebase operation.
+
+    Args:
+        repo: Repository to rebase in
+        upstream: Upstream branch/commit to rebase onto
+        onto: Specific commit to rebase onto (defaults to upstream)
+        branch: Branch to rebase (defaults to current branch)
+
+    Returns:
+        List of new commit SHAs created by rebase
+
+    Raises:
+        RebaseConflict: If conflicts occur during rebase
+        RebaseError: For other rebase errors
+    """
+    rebaser = Rebaser(repo)
+
+    # Start rebase
+    rebaser.start(upstream, onto, branch)
+
+    # Continue rebase
+    result = rebaser.continue_()
+    if result is not None:
+        # Conflicts
+        raise RebaseConflict(result[1])
+
+    # Return the SHAs of the rebased commits
+    return [c.id for c in rebaser._done]

+ 28 - 0
dulwich/repo.py

@@ -736,6 +736,13 @@ class BaseRepo:
         """
         raise NotImplementedError(self.set_description)
 
+    def get_rebase_state_manager(self):
+        """Get the appropriate rebase state manager for this repository.
+
+        Returns: RebaseStateManager instance
+        """
+        raise NotImplementedError(self.get_rebase_state_manager)
+
     def get_config_stack(self) -> "StackedConfig":
         """Return a config stack for this repository.
 
@@ -1713,6 +1720,18 @@ class Repo(BaseRepo):
             ret.path = path
             return ret
 
+    def get_rebase_state_manager(self):
+        """Get the appropriate rebase state manager for this repository.
+
+        Returns: DiskRebaseStateManager instance
+        """
+        import os
+
+        from .rebase import DiskRebaseStateManager
+
+        path = os.path.join(self.controldir(), "rebase-merge")
+        return DiskRebaseStateManager(path)
+
     def get_description(self):
         """Retrieve the description of this repository.
 
@@ -2074,6 +2093,15 @@ class MemoryRepo(BaseRepo):
         """
         return self._config
 
+    def get_rebase_state_manager(self):
+        """Get the appropriate rebase state manager for this repository.
+
+        Returns: MemoryRebaseStateManager instance
+        """
+        from .rebase import MemoryRebaseStateManager
+
+        return MemoryRebaseStateManager(self)
+
     @classmethod
     def init_bare(cls, objects, refs, format: Optional[int] = None):
         """Create a new bare repository in memory.

+ 456 - 0
tests/test_rebase.py

@@ -0,0 +1,456 @@
+# test_rebase.py -- rebase tests
+# Copyright (C) 2025 Dulwich contributors
+#
+# SPDX-License-Identifier: Apache-2.0 OR GPL-2.0-or-later
+# Dulwich is dual-licensed under the Apache License, Version 2.0 and the GNU
+# General Public License as public by the Free Software Foundation; version 2.0
+# or (at your option) any later version. You can redistribute it and/or
+# modify it under the terms of either of these two licenses.
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# You should have received a copy of the licenses; if not, see
+# <http://www.gnu.org/licenses/> for a copy of the GNU General Public License
+# and <http://www.apache.org/licenses/LICENSE-2.0> for a copy of the Apache
+# License, Version 2.0.
+#
+
+"""Tests for dulwich.rebase."""
+
+import os
+import tempfile
+
+from dulwich.objects import Blob, Commit, Tree
+from dulwich.rebase import RebaseConflict, Rebaser, rebase
+from dulwich.repo import MemoryRepo, Repo
+from dulwich.tests.utils import make_commit
+
+from . import TestCase
+
+
+class RebaserTestCase(TestCase):
+    """Tests for the Rebaser class."""
+
+    def setUp(self):
+        """Set up test repository."""
+        super().setUp()
+        self.repo = MemoryRepo()
+
+    def _setup_initial_commit(self):
+        """Set up initial commit for tests."""
+        # Create initial commit
+        blob = Blob.from_string(b"Initial content\n")
+        self.repo.object_store.add_object(blob)
+
+        tree = Tree()
+        tree.add(b"file.txt", 0o100644, blob.id)
+        self.repo.object_store.add_object(tree)
+
+        self.initial_commit = Commit()
+        self.initial_commit.tree = tree.id
+        self.initial_commit.parents = []
+        self.initial_commit.message = b"Initial commit"
+        self.initial_commit.committer = b"Test User <test@example.com>"
+        self.initial_commit.author = b"Test User <test@example.com>"
+        self.initial_commit.commit_time = 1000000
+        self.initial_commit.author_time = 1000000
+        self.initial_commit.commit_timezone = 0
+        self.initial_commit.author_timezone = 0
+        self.repo.object_store.add_object(self.initial_commit)
+
+        # Set up branches
+        self.repo.refs[b"refs/heads/master"] = self.initial_commit.id
+        self.repo.refs.set_symbolic_ref(b"HEAD", b"refs/heads/master")
+
+    def test_simple_rebase(self):
+        """Test simple rebase with no conflicts."""
+        self._setup_initial_commit()
+        # Create feature branch with one commit
+        feature_blob = Blob.from_string(b"Feature content\n")
+        self.repo.object_store.add_object(feature_blob)
+
+        feature_tree = Tree()
+        feature_tree.add(b"feature.txt", 0o100644, feature_blob.id)
+        feature_tree.add(
+            b"file.txt", 0o100644, self.repo[self.initial_commit.tree][b"file.txt"][1]
+        )
+        self.repo.object_store.add_object(feature_tree)
+
+        feature_commit = Commit()
+        feature_commit.tree = feature_tree.id
+        feature_commit.parents = [self.initial_commit.id]
+        feature_commit.message = b"Add feature"
+        feature_commit.committer = b"Test User <test@example.com>"
+        feature_commit.author = b"Test User <test@example.com>"
+        feature_commit.commit_time = 1000100
+        feature_commit.author_time = 1000100
+        feature_commit.commit_timezone = 0
+        feature_commit.author_timezone = 0
+        self.repo.object_store.add_object(feature_commit)
+        self.repo.refs[b"refs/heads/feature"] = feature_commit.id
+
+        # Create main branch advancement
+        main_blob = Blob.from_string(b"Main advancement\n")
+        self.repo.object_store.add_object(main_blob)
+
+        main_tree = Tree()
+        main_tree.add(b"main.txt", 0o100644, main_blob.id)
+        main_tree.add(
+            b"file.txt", 0o100644, self.repo[self.initial_commit.tree][b"file.txt"][1]
+        )
+        self.repo.object_store.add_object(main_tree)
+
+        main_commit = Commit()
+        main_commit.tree = main_tree.id
+        main_commit.parents = [self.initial_commit.id]
+        main_commit.message = b"Main advancement"
+        main_commit.committer = b"Test User <test@example.com>"
+        main_commit.author = b"Test User <test@example.com>"
+        main_commit.commit_time = 1000200
+        main_commit.author_time = 1000200
+        main_commit.commit_timezone = 0
+        main_commit.author_timezone = 0
+        self.repo.object_store.add_object(main_commit)
+        self.repo.refs[b"refs/heads/master"] = main_commit.id
+
+        # Switch to feature branch
+        self.repo.refs.set_symbolic_ref(b"HEAD", b"refs/heads/feature")
+
+        # Check refs before rebase
+        main_ref_before = self.repo.refs[b"refs/heads/master"]
+        feature_ref_before = self.repo.refs[b"refs/heads/feature"]
+
+        # Double check that refs are correctly set up
+        self.assertEqual(main_ref_before, main_commit.id)
+        self.assertEqual(feature_ref_before, feature_commit.id)
+
+        # Perform rebase
+        rebaser = Rebaser(self.repo)
+        commits = rebaser.start(b"refs/heads/master", branch=b"refs/heads/feature")
+
+        self.assertEqual(len(commits), 1)
+        self.assertEqual(commits[0].id, feature_commit.id)
+
+        # Continue rebase
+        result = rebaser.continue_()
+        self.assertIsNone(result)  # Rebase complete
+
+        # Check that feature branch was updated
+        new_feature_head = self.repo.refs[b"refs/heads/feature"]
+        new_commit = self.repo[new_feature_head]
+
+        # Should have main commit as parent
+        self.assertEqual(new_commit.parents, [main_commit.id])
+
+        # Should have same tree as original (both files present)
+        new_tree = self.repo[new_commit.tree]
+        self.assertIn(b"feature.txt", new_tree)
+        self.assertIn(b"main.txt", new_tree)
+        self.assertIn(b"file.txt", new_tree)
+
+    def test_rebase_with_conflicts(self):
+        """Test rebase with merge conflicts."""
+        self._setup_initial_commit()
+        # Create feature branch with conflicting change
+        feature_blob = Blob.from_string(b"Feature change to file\n")
+        self.repo.object_store.add_object(feature_blob)
+
+        feature_tree = Tree()
+        feature_tree.add(b"file.txt", 0o100644, feature_blob.id)
+        self.repo.object_store.add_object(feature_tree)
+
+        feature_commit = Commit()
+        feature_commit.tree = feature_tree.id
+        feature_commit.parents = [self.initial_commit.id]
+        feature_commit.message = b"Feature change"
+        feature_commit.committer = b"Test User <test@example.com>"
+        feature_commit.author = b"Test User <test@example.com>"
+        feature_commit.commit_time = 1000100
+        feature_commit.author_time = 1000100
+        feature_commit.commit_timezone = 0
+        feature_commit.author_timezone = 0
+        self.repo.object_store.add_object(feature_commit)
+        self.repo.refs[b"refs/heads/feature"] = feature_commit.id
+
+        # Create main branch with conflicting change
+        main_blob = Blob.from_string(b"Main change to file\n")
+        self.repo.object_store.add_object(main_blob)
+
+        main_tree = Tree()
+        main_tree.add(b"file.txt", 0o100644, main_blob.id)
+        self.repo.object_store.add_object(main_tree)
+
+        main_commit = Commit()
+        main_commit.tree = main_tree.id
+        main_commit.parents = [self.initial_commit.id]
+        main_commit.message = b"Main change"
+        main_commit.committer = b"Test User <test@example.com>"
+        main_commit.author = b"Test User <test@example.com>"
+        main_commit.commit_time = 1000200
+        main_commit.author_time = 1000200
+        main_commit.commit_timezone = 0
+        main_commit.author_timezone = 0
+        self.repo.object_store.add_object(main_commit)
+        self.repo.refs[b"refs/heads/master"] = main_commit.id
+
+        # Attempt rebase - should fail with conflicts
+        with self.assertRaises(RebaseConflict) as cm:
+            rebase(self.repo, b"refs/heads/master", branch=b"refs/heads/feature")
+
+        self.assertIn(b"file.txt", cm.exception.conflicted_files)
+
+    def test_abort_rebase(self):
+        """Test aborting a rebase."""
+        self._setup_initial_commit()
+        # Set up branches similar to simple rebase test
+        feature_blob = Blob.from_string(b"Feature content\n")
+        self.repo.object_store.add_object(feature_blob)
+
+        feature_tree = Tree()
+        feature_tree.add(b"feature.txt", 0o100644, feature_blob.id)
+        feature_tree.add(
+            b"file.txt", 0o100644, self.repo[self.initial_commit.tree][b"file.txt"][1]
+        )
+        self.repo.object_store.add_object(feature_tree)
+
+        feature_commit = Commit()
+        feature_commit.tree = feature_tree.id
+        feature_commit.parents = [self.initial_commit.id]
+        feature_commit.message = b"Add feature"
+        feature_commit.committer = b"Test User <test@example.com>"
+        feature_commit.author = b"Test User <test@example.com>"
+        feature_commit.commit_time = 1000100
+        feature_commit.author_time = 1000100
+        feature_commit.commit_timezone = 0
+        feature_commit.author_timezone = 0
+        self.repo.object_store.add_object(feature_commit)
+        self.repo.refs[b"refs/heads/feature"] = feature_commit.id
+        self.repo.refs.set_symbolic_ref(b"HEAD", b"refs/heads/feature")
+
+        # Start rebase
+        rebaser = Rebaser(self.repo)
+        rebaser.start(b"refs/heads/master")
+
+        # Abort rebase
+        rebaser.abort()
+
+        # Check that HEAD is restored
+        self.assertEqual(self.repo.refs.read_ref(b"HEAD"), b"ref: refs/heads/feature")
+        self.assertEqual(self.repo.refs[b"refs/heads/feature"], feature_commit.id)
+
+        # Check that REBASE_HEAD is cleaned up
+        self.assertNotIn(b"REBASE_HEAD", self.repo.refs)
+
+    def test_rebase_no_commits(self):
+        """Test rebase when already up to date."""
+        self._setup_initial_commit()
+        # Both branches point to same commit
+        self.repo.refs[b"refs/heads/feature"] = self.initial_commit.id
+        self.repo.refs.set_symbolic_ref(b"HEAD", b"refs/heads/feature")
+
+        # Perform rebase
+        result = rebase(self.repo, b"refs/heads/master")
+
+        # Should return empty list (no new commits)
+        self.assertEqual(result, [])
+
+    def test_rebase_onto(self):
+        """Test rebase with --onto option."""
+        self._setup_initial_commit()
+        # Create a chain of commits: initial -> A -> B -> C
+        blob_a = Blob.from_string(b"Commit A\n")
+        self.repo.object_store.add_object(blob_a)
+
+        tree_a = Tree()
+        tree_a.add(b"a.txt", 0o100644, blob_a.id)
+        tree_a.add(
+            b"file.txt", 0o100644, self.repo[self.initial_commit.tree][b"file.txt"][1]
+        )
+        self.repo.object_store.add_object(tree_a)
+
+        commit_a = make_commit(
+            id=b"a" * 40,
+            tree=tree_a.id,
+            parents=[self.initial_commit.id],
+            message=b"Commit A",
+            committer=b"Test User <test@example.com>",
+            author=b"Test User <test@example.com>",
+            commit_time=1000100,
+            author_time=1000100,
+        )
+        self.repo.object_store.add_object(commit_a)
+
+        blob_b = Blob.from_string(b"Commit B\n")
+        self.repo.object_store.add_object(blob_b)
+
+        tree_b = Tree()
+        tree_b.add(b"b.txt", 0o100644, blob_b.id)
+        tree_b.add(b"a.txt", 0o100644, blob_a.id)
+        tree_b.add(
+            b"file.txt", 0o100644, self.repo[self.initial_commit.tree][b"file.txt"][1]
+        )
+        self.repo.object_store.add_object(tree_b)
+
+        commit_b = make_commit(
+            id=b"b" * 40,
+            tree=tree_b.id,
+            parents=[commit_a.id],
+            message=b"Commit B",
+            committer=b"Test User <test@example.com>",
+            author=b"Test User <test@example.com>",
+            commit_time=1000200,
+            author_time=1000200,
+        )
+        self.repo.object_store.add_object(commit_b)
+
+        blob_c = Blob.from_string(b"Commit C\n")
+        self.repo.object_store.add_object(blob_c)
+
+        tree_c = Tree()
+        tree_c.add(b"c.txt", 0o100644, blob_c.id)
+        tree_c.add(b"b.txt", 0o100644, blob_b.id)
+        tree_c.add(b"a.txt", 0o100644, blob_a.id)
+        tree_c.add(
+            b"file.txt", 0o100644, self.repo[self.initial_commit.tree][b"file.txt"][1]
+        )
+        self.repo.object_store.add_object(tree_c)
+
+        commit_c = make_commit(
+            id=b"c" * 40,
+            tree=tree_c.id,
+            parents=[commit_b.id],
+            message=b"Commit C",
+            committer=b"Test User <test@example.com>",
+            author=b"Test User <test@example.com>",
+            commit_time=1000300,
+            author_time=1000300,
+        )
+        self.repo.object_store.add_object(commit_c)
+
+        # Create separate branch at commit A
+        self.repo.refs[b"refs/heads/topic"] = commit_c.id
+        self.repo.refs[b"refs/heads/newbase"] = commit_a.id
+        self.repo.refs.set_symbolic_ref(b"HEAD", b"refs/heads/topic")
+
+        # Rebase B and C onto initial commit (skipping A)
+        rebaser = Rebaser(self.repo)
+        commits = rebaser.start(
+            upstream=commit_a.id,
+            onto=self.initial_commit.id,
+            branch=b"refs/heads/topic",
+        )
+
+        # Should rebase commits B and C
+        self.assertEqual(len(commits), 2)
+        self.assertEqual(commits[0].id, commit_b.id)
+        self.assertEqual(commits[1].id, commit_c.id)
+
+        # Continue rebase
+        result = rebaser.continue_()
+        self.assertIsNone(result)
+
+        # Check result
+        new_head = self.repo.refs[b"refs/heads/topic"]
+        new_c = self.repo[new_head]
+        new_b = self.repo[new_c.parents[0]]
+
+        # B should now have initial commit as parent (not A)
+        self.assertEqual(new_b.parents, [self.initial_commit.id])
+
+        # Trees should still have b.txt and c.txt but not a.txt
+        new_b_tree = self.repo[new_b.tree]
+        self.assertIn(b"b.txt", new_b_tree)
+        self.assertNotIn(b"a.txt", new_b_tree)
+
+        new_c_tree = self.repo[new_c.tree]
+        self.assertIn(b"c.txt", new_c_tree)
+        self.assertIn(b"b.txt", new_c_tree)
+        self.assertNotIn(b"a.txt", new_c_tree)
+
+
+class RebasePorcelainTestCase(TestCase):
+    """Tests for the porcelain rebase function."""
+
+    def setUp(self):
+        """Set up test repository."""
+        super().setUp()
+        self.test_dir = tempfile.mkdtemp()
+        self.repo = Repo.init(self.test_dir)
+
+        # Create initial commit
+        with open(os.path.join(self.test_dir, "README.md"), "wb") as f:
+            f.write(b"# Test Repository\n")
+
+        self.repo.stage(["README.md"])
+        self.initial_commit = self.repo.do_commit(
+            b"Initial commit",
+            committer=b"Test User <test@example.com>",
+            author=b"Test User <test@example.com>",
+        )
+
+    def tearDown(self):
+        """Clean up test directory."""
+        import shutil
+
+        shutil.rmtree(self.test_dir)
+
+    def test_porcelain_rebase(self):
+        """Test rebase through porcelain interface."""
+        from dulwich import porcelain
+
+        # Create and checkout feature branch
+        self.repo.refs[b"refs/heads/feature"] = self.initial_commit
+        porcelain.checkout_branch(self.repo, "feature")
+
+        # Add commit to feature branch
+        with open(os.path.join(self.test_dir, "feature.txt"), "wb") as f:
+            f.write(b"Feature file\n")
+
+        porcelain.add(self.repo, ["feature.txt"])
+        porcelain.commit(
+            self.repo,
+            message="Add feature",
+            author="Test User <test@example.com>",
+            committer="Test User <test@example.com>",
+        )
+
+        # Switch to main and add different commit
+        porcelain.checkout_branch(self.repo, "master")
+
+        with open(os.path.join(self.test_dir, "main.txt"), "wb") as f:
+            f.write(b"Main file\n")
+
+        porcelain.add(self.repo, ["main.txt"])
+        porcelain.commit(
+            self.repo,
+            message="Main update",
+            author="Test User <test@example.com>",
+            committer="Test User <test@example.com>",
+        )
+
+        # Switch back to feature and rebase
+        porcelain.checkout_branch(self.repo, "feature")
+
+        # Perform rebase
+        new_shas = porcelain.rebase(self.repo, "master")
+
+        # Should have rebased one commit
+        self.assertEqual(len(new_shas), 1)
+
+        # Check that the rebased commit has the correct parent and tree
+        feature_head = self.repo.refs[b"refs/heads/feature"]
+        feature_commit_obj = self.repo[feature_head]
+
+        # Should have master as parent
+        master_head = self.repo.refs[b"refs/heads/master"]
+        self.assertEqual(feature_commit_obj.parents, [master_head])
+
+        # Tree should have both files
+        tree = self.repo[feature_commit_obj.tree]
+        self.assertIn(b"feature.txt", tree)
+        self.assertIn(b"main.txt", tree)
+        self.assertIn(b"README.md", tree)