Quellcode durchsuchen

Add interactive rebase support

This adds support for interactive rebase (`git rebase -i`) with all standard
git rebase commands: pick, reword, edit, squash, fixup, drop, exec, and break.

Fixes #1696
Jelmer Vernooij vor 5 Monaten
Ursprung
Commit
1abe3f7a3c
5 geänderte Dateien mit 1147 neuen und 48 gelöschten Zeilen
  1. 4 0
      NEWS
  2. 60 14
      dulwich/cli.py
  3. 101 18
      dulwich/porcelain.py
  4. 684 15
      dulwich/rebase.py
  5. 298 1
      tests/test_rebase.py

+ 4 - 0
NEWS

@@ -18,6 +18,10 @@
  * Fix Windows config loading to only use current Git config path,
    avoiding loading older config files.  (Jelmer Vernooij, #1732)
 
+ * Add interactive rebase support with ``git rebase -i``, including support
+   for pick, reword, edit, squash, fixup, drop, exec, and break commands.
+   (Jelmer Vernooij, #1696)
+
 0.24.1	2025-08-01
 
  * Require ``typing_extensions`` on Python 3.10.

+ 60 - 14
dulwich/cli.py

@@ -2315,6 +2315,14 @@ class cmd_rebase(Command):
         parser.add_argument(
             "--branch", type=str, help="Branch to rebase (default: current)"
         )
+        parser.add_argument(
+            "-i", "--interactive", action="store_true", help="Interactive rebase"
+        )
+        parser.add_argument(
+            "--edit-todo",
+            action="store_true",
+            help="Edit the todo list during an interactive rebase",
+        )
         parser.add_argument(
             "--abort", action="store_true", help="Abort an in-progress rebase"
         )
@@ -2341,10 +2349,33 @@ class cmd_rebase(Command):
 
         if args.continue_rebase:
             try:
-                new_shas = porcelain.rebase(
-                    ".", args.upstream or "HEAD", continue_rebase=True
-                )
-                print("Rebase complete.")
+                # Check if interactive rebase is in progress
+                if porcelain.is_interactive_rebase("."):
+                    result = porcelain.rebase(
+                        ".",
+                        args.upstream or "HEAD",
+                        continue_rebase=True,
+                        interactive=True,
+                    )
+                    if result:
+                        print("Rebase complete.")
+                    else:
+                        print("Rebase paused. Use --continue to resume.")
+                else:
+                    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
+
+        if args.edit_todo:
+            # Edit todo list for interactive rebase
+            try:
+                porcelain.rebase(".", args.upstream or "HEAD", edit_todo=True)
+                print("Todo list updated.")
             except porcelain.Error as e:
                 print(f"Error: {e}")
                 return 1
@@ -2356,17 +2387,32 @@ class cmd_rebase(Command):
             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.")
+            if args.interactive:
+                # Interactive rebase
+                result = porcelain.rebase(
+                    ".",
+                    args.upstream,
+                    onto=args.onto,
+                    branch=args.branch,
+                    interactive=True,
+                )
+                if result:
+                    print("Interactive rebase started. Edit the todo list and save.")
+                else:
+                    print("No commits to rebase.")
             else:
-                print("Already up to date.")
+                # Regular rebase
+                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:

+ 101 - 18
dulwich/porcelain.py

@@ -4691,6 +4691,25 @@ def count_objects(repo: RepoPath = ".", verbose=False) -> CountObjectsResult:
         )
 
 
+def is_interactive_rebase(repo: Union[Repo, str]) -> bool:
+    """Check if an interactive rebase is in progress.
+
+    Args:
+      repo: Repository to check
+
+    Returns:
+      True if interactive rebase is in progress, False otherwise
+    """
+    with open_repo_closing(repo) as r:
+        state_manager = r.get_rebase_state_manager()
+        if not state_manager.exists():
+            return False
+
+        # Check if todo file exists
+        todo = state_manager.load_todo()
+        return todo is not None
+
+
 def rebase(
     repo: Union[Repo, str],
     upstream: Union[bytes, str],
@@ -4699,6 +4718,8 @@ def rebase(
     abort: bool = False,
     continue_rebase: bool = False,
     skip: bool = False,
+    interactive: bool = False,
+    edit_todo: bool = False,
 ) -> list[bytes]:
     """Rebase commits onto another branch.
 
@@ -4710,6 +4731,8 @@ def rebase(
       abort: Abort an in-progress rebase
       continue_rebase: Continue an in-progress rebase
       skip: Skip current commit and continue rebase
+      interactive: Start an interactive rebase
+      edit_todo: Edit the todo list of an interactive rebase
 
     Returns:
       List of new commit SHAs created by rebase
@@ -4717,7 +4740,17 @@ def rebase(
     Raises:
       Error: If rebase fails or conflicts occur
     """
-    from .rebase import RebaseConflict, RebaseError, Rebaser
+    from .cli import launch_editor
+    from .rebase import (
+        RebaseConflict,
+        RebaseError,
+        Rebaser,
+        process_interactive_rebase,
+        start_interactive,
+    )
+    from .rebase import (
+        edit_todo as edit_todo_func,
+    )
 
     with open_repo_closing(repo) as r:
         rebaser = Rebaser(r)
@@ -4729,17 +4762,45 @@ def rebase(
             except RebaseError as e:
                 raise Error(str(e))
 
+        if edit_todo:
+            # Edit the todo list of an interactive rebase
+            try:
+                edit_todo_func(r, launch_editor)
+                print("Todo list updated. Continue with 'rebase --continue'")
+                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])}"
+                if interactive:
+                    # Continue interactive rebase
+                    is_complete, pause_reason = process_interactive_rebase(
+                        r, editor_callback=launch_editor
                     )
+                    if is_complete:
+                        return [c.id for c in rebaser._done]
+                    else:
+                        if pause_reason == "conflict":
+                            raise Error("Conflicts detected. Resolve and continue.")
+                        elif pause_reason == "edit":
+                            print("Stopped for editing. Make changes and continue.")
+                        elif pause_reason == "break":
+                            print("Rebase paused at break. Continue when ready.")
+                        else:
+                            print(f"Rebase paused: {pause_reason}")
+                        return []
+                else:
+                    # Continue regular rebase
+                    result = rebaser.continue_()
+                    if result is None:
+                        # Rebase complete
+                        return [c.id for c in rebaser._done]
+                    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))
 
@@ -4752,17 +4813,39 @@ def rebase(
             branch = branch.encode("utf-8") if branch else None
 
         try:
-            # Start rebase
-            rebaser.start(upstream, onto, branch)
+            if interactive:
+                # Start interactive rebase
+                todo = start_interactive(r, upstream, onto, branch, launch_editor)
 
-            # Continue rebase automatically
-            result = rebaser.continue_()
-            if result is not None:
-                # Conflicts
-                raise RebaseConflict(result[1])
+                # Process the todo list
+                is_complete, pause_reason = process_interactive_rebase(
+                    r, todo, editor_callback=launch_editor
+                )
+
+                if is_complete:
+                    return [c.id for c in rebaser._done]
+                else:
+                    if pause_reason == "conflict":
+                        raise Error("Conflicts detected. Resolve and continue.")
+                    elif pause_reason == "edit":
+                        print("Stopped for editing. Make changes and continue.")
+                    elif pause_reason == "break":
+                        print("Rebase paused at break. Continue when ready.")
+                    else:
+                        print(f"Rebase paused: {pause_reason}")
+                    return []
+            else:
+                # Regular 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]
+                # Return the SHAs of the rebased commits
+                return [c.id for c in rebaser._done]
 
         except RebaseConflict as e:
             raise Error(str(e))

+ 684 - 15
dulwich/rebase.py

@@ -21,6 +21,11 @@
 
 """Git rebase implementation."""
 
+import os
+import shutil
+import subprocess
+from dataclasses import dataclass
+from enum import Enum
 from typing import Optional, Protocol
 
 from dulwich.graph import find_merge_base
@@ -48,6 +53,314 @@ class RebaseAbort(RebaseError):
     """Raised when rebase is aborted."""
 
 
+class RebaseTodoCommand(Enum):
+    """Enum for rebase todo commands."""
+
+    PICK = "pick"
+    REWORD = "reword"
+    EDIT = "edit"
+    SQUASH = "squash"
+    FIXUP = "fixup"
+    EXEC = "exec"
+    BREAK = "break"
+    DROP = "drop"
+    LABEL = "label"
+    RESET = "reset"
+    MERGE = "merge"
+
+    @classmethod
+    def from_string(cls, s: str) -> "RebaseTodoCommand":
+        """Parse a command from its string representation.
+
+        Args:
+            s: Command string (can be abbreviated)
+
+        Returns:
+            RebaseTodoCommand enum value
+
+        Raises:
+            ValueError: If command is not recognized
+        """
+        s = s.lower()
+        # Support abbreviations
+        abbreviations = {
+            "p": cls.PICK,
+            "r": cls.REWORD,
+            "e": cls.EDIT,
+            "s": cls.SQUASH,
+            "f": cls.FIXUP,
+            "x": cls.EXEC,
+            "b": cls.BREAK,
+            "d": cls.DROP,
+            "l": cls.LABEL,
+            "t": cls.RESET,
+            "m": cls.MERGE,
+        }
+
+        if s in abbreviations:
+            return abbreviations[s]
+
+        # Try full command name
+        try:
+            return cls(s)
+        except ValueError:
+            raise ValueError(f"Unknown rebase command: {s}")
+
+
+@dataclass
+class RebaseTodoEntry:
+    """Represents a single entry in a rebase todo list."""
+
+    command: RebaseTodoCommand
+    commit_sha: Optional[bytes] = None  # Store as hex string encoded as bytes
+    short_message: Optional[str] = None
+    arguments: Optional[str] = None
+
+    def to_string(self, abbreviate: bool = False) -> str:
+        """Convert to git-rebase-todo format string.
+
+        Args:
+            abbreviate: Use abbreviated command names
+
+        Returns:
+            String representation for todo file
+        """
+        if abbreviate:
+            cmd_map = {
+                RebaseTodoCommand.PICK: "p",
+                RebaseTodoCommand.REWORD: "r",
+                RebaseTodoCommand.EDIT: "e",
+                RebaseTodoCommand.SQUASH: "s",
+                RebaseTodoCommand.FIXUP: "f",
+                RebaseTodoCommand.EXEC: "x",
+                RebaseTodoCommand.BREAK: "b",
+                RebaseTodoCommand.DROP: "d",
+                RebaseTodoCommand.LABEL: "l",
+                RebaseTodoCommand.RESET: "t",
+                RebaseTodoCommand.MERGE: "m",
+            }
+            cmd = cmd_map.get(self.command, self.command.value)
+        else:
+            cmd = self.command.value
+
+        parts = [cmd]
+
+        if self.commit_sha:
+            # Use short SHA (first 7 chars) like Git does
+            parts.append(self.commit_sha.decode()[:7])
+
+        if self.arguments:
+            parts.append(self.arguments)
+        elif self.short_message:
+            parts.append(self.short_message)
+
+        return " ".join(parts)
+
+    @classmethod
+    def from_string(cls, line: str) -> Optional["RebaseTodoEntry"]:
+        """Parse a todo entry from a line.
+
+        Args:
+            line: Line from git-rebase-todo file
+
+        Returns:
+            RebaseTodoEntry or None if line is empty/comment
+        """
+        line = line.strip()
+
+        # Skip empty lines and comments
+        if not line or line.startswith("#"):
+            return None
+
+        parts = line.split(None, 2)
+        if not parts:
+            return None
+
+        command_str = parts[0]
+        try:
+            command = RebaseTodoCommand.from_string(command_str)
+        except ValueError:
+            # Unknown command, skip
+            return None
+
+        commit_sha = None
+        short_message = None
+        arguments = None
+
+        if command in (
+            RebaseTodoCommand.EXEC,
+            RebaseTodoCommand.LABEL,
+            RebaseTodoCommand.RESET,
+        ):
+            # These commands take arguments instead of commit SHA
+            if len(parts) > 1:
+                arguments = " ".join(parts[1:])
+        elif command == RebaseTodoCommand.BREAK:
+            # Break has no arguments
+            pass
+        else:
+            # Commands that operate on commits
+            if len(parts) > 1:
+                # Store SHA as hex string encoded as bytes
+                commit_sha = parts[1].encode()
+
+                # Parse commit message if present
+                if len(parts) > 2:
+                    short_message = parts[2]
+
+        return cls(
+            command=command,
+            commit_sha=commit_sha,
+            short_message=short_message,
+            arguments=arguments,
+        )
+
+
+class RebaseTodo:
+    """Manages the git-rebase-todo file for interactive rebase."""
+
+    def __init__(self, entries: Optional[list[RebaseTodoEntry]] = None):
+        """Initialize RebaseTodo.
+
+        Args:
+            entries: List of todo entries
+        """
+        self.entries = entries or []
+        self.current_index = 0
+
+    def add_entry(self, entry: RebaseTodoEntry) -> None:
+        """Add an entry to the todo list."""
+        self.entries.append(entry)
+
+    def get_current(self) -> Optional[RebaseTodoEntry]:
+        """Get the current todo entry."""
+        if self.current_index < len(self.entries):
+            return self.entries[self.current_index]
+        return None
+
+    def advance(self) -> None:
+        """Move to the next todo entry."""
+        self.current_index += 1
+
+    def is_complete(self) -> bool:
+        """Check if all entries have been processed."""
+        return self.current_index >= len(self.entries)
+
+    def to_string(self, include_comments: bool = True) -> str:
+        """Convert to git-rebase-todo file format.
+
+        Args:
+            include_comments: Include helpful comments
+
+        Returns:
+            String content for todo file
+        """
+        lines = []
+
+        # Add entries from current position onward
+        for entry in self.entries[self.current_index :]:
+            lines.append(entry.to_string())
+
+        if include_comments:
+            lines.append("")
+            lines.append("# Rebase in progress")
+            lines.append("#")
+            lines.append("# Commands:")
+            lines.append("# p, pick <commit> = use commit")
+            lines.append(
+                "# r, reword <commit> = use commit, but edit the commit message"
+            )
+            lines.append("# e, edit <commit> = use commit, but stop for amending")
+            lines.append(
+                "# s, squash <commit> = use commit, but meld into previous commit"
+            )
+            lines.append(
+                "# f, fixup [-C | -c] <commit> = like 'squash' but keep only the previous"
+            )
+            lines.append(
+                "#                    commit's log message, unless -C is used, in which case"
+            )
+            lines.append(
+                "#                    keep only this commit's message; -c is same as -C but"
+            )
+            lines.append("#                    opens the editor")
+            lines.append(
+                "# x, exec <command> = run command (the rest of the line) using shell"
+            )
+            lines.append(
+                "# b, break = stop here (continue rebase later with 'git rebase --continue')"
+            )
+            lines.append("# d, drop <commit> = remove commit")
+            lines.append("# l, label <label> = label current HEAD with a name")
+            lines.append("# t, reset <label> = reset HEAD to a label")
+            lines.append("# m, merge [-C <commit> | -c <commit>] <label> [# <oneline>]")
+            lines.append(
+                "# .       create a merge commit using the original merge commit's"
+            )
+            lines.append(
+                "# .       message (or the oneline, if no original merge commit was"
+            )
+            lines.append(
+                "# .       specified); use -c <commit> to reword the commit message"
+            )
+            lines.append("#")
+            lines.append(
+                "# These lines can be re-ordered; they are executed from top to bottom."
+            )
+            lines.append("#")
+            lines.append("# If you remove a line here THAT COMMIT WILL BE LOST.")
+            lines.append("#")
+            lines.append(
+                "# However, if you remove everything, the rebase will be aborted."
+            )
+            lines.append("#")
+
+        return "\n".join(lines)
+
+    @classmethod
+    def from_string(cls, content: str) -> "RebaseTodo":
+        """Parse a git-rebase-todo file.
+
+        Args:
+            content: Content of todo file
+
+        Returns:
+            RebaseTodo instance
+        """
+        entries = []
+        for line in content.splitlines():
+            entry = RebaseTodoEntry.from_string(line)
+            if entry:
+                entries.append(entry)
+
+        return cls(entries)
+
+    @classmethod
+    def from_commits(cls, commits: list[Commit]) -> "RebaseTodo":
+        """Create a todo list from a list of commits.
+
+        Args:
+            commits: List of commits to rebase (in chronological order)
+
+        Returns:
+            RebaseTodo instance with pick commands for each commit
+        """
+        entries = []
+        for commit in commits:
+            # Extract first line of commit message
+            message = commit.message.decode("utf-8", errors="replace")
+            short_message = message.split("\n")[0][:50]
+
+            entry = RebaseTodoEntry(
+                command=RebaseTodoCommand.PICK,
+                commit_sha=commit.id,  # Already bytes
+                short_message=short_message,
+            )
+            entries.append(entry)
+
+        return cls(entries)
+
+
 class RebaseStateManager(Protocol):
     """Protocol for managing rebase state."""
 
@@ -82,6 +395,14 @@ class RebaseStateManager(Protocol):
         """Check if rebase state exists."""
         ...
 
+    def save_todo(self, todo: RebaseTodo) -> None:
+        """Save interactive rebase todo list."""
+        ...
+
+    def load_todo(self) -> Optional[RebaseTodo]:
+        """Load interactive rebase todo list."""
+        ...
+
 
 class DiskRebaseStateManager:
     """Manages rebase state on disk using same files as C Git."""
@@ -103,8 +424,6 @@ class DiskRebaseStateManager:
         done: list[Commit],
     ) -> None:
         """Save rebase state to disk."""
-        import os
-
         # Ensure the directory exists
         os.makedirs(self.path, exist_ok=True)
 
@@ -132,12 +451,8 @@ class DiskRebaseStateManager:
             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)
 
@@ -162,14 +477,10 @@ class DiskRebaseStateManager:
         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()
@@ -178,8 +489,6 @@ class DiskRebaseStateManager:
 
     def clean(self) -> None:
         """Clean up rebase state files."""
-        import shutil
-
         try:
             shutil.rmtree(self.path)
         except FileNotFoundError:
@@ -188,10 +497,29 @@ class DiskRebaseStateManager:
 
     def exists(self) -> bool:
         """Check if rebase state exists."""
-        import os
-
         return os.path.exists(os.path.join(self.path, "orig-head"))
 
+    def save_todo(self, todo: RebaseTodo) -> None:
+        """Save the interactive rebase todo list.
+
+        Args:
+            todo: The RebaseTodo object to save
+        """
+        todo_content = todo.to_string()
+        self._write_file("git-rebase-todo", todo_content.encode("utf-8"))
+
+    def load_todo(self) -> Optional[RebaseTodo]:
+        """Load the interactive rebase todo list.
+
+        Returns:
+            RebaseTodo object or None if no todo file exists
+        """
+        todo_content = self._read_file("git-rebase-todo")
+        if todo_content:
+            todo_str = todo_content.decode("utf-8", errors="replace")
+            return RebaseTodo.from_string(todo_str)
+        return None
+
 
 class MemoryRebaseStateManager:
     """Manages rebase state in memory for MemoryRepo."""
@@ -199,6 +527,7 @@ class MemoryRebaseStateManager:
     def __init__(self, repo: Repo) -> None:
         self.repo = repo
         self._state: Optional[dict] = None
+        self._todo: Optional[RebaseTodo] = None
 
     def save(
         self,
@@ -241,11 +570,28 @@ class MemoryRebaseStateManager:
     def clean(self) -> None:
         """Clean up rebase state."""
         self._state = None
+        self._todo = None
 
     def exists(self) -> bool:
         """Check if rebase state exists."""
         return self._state is not None
 
+    def save_todo(self, todo: RebaseTodo) -> None:
+        """Save the interactive rebase todo list.
+
+        Args:
+            todo: The RebaseTodo object to save
+        """
+        self._todo = todo
+
+    def load_todo(self) -> Optional[RebaseTodo]:
+        """Load the interactive rebase todo list.
+
+        Returns:
+            RebaseTodo object or None if no todo exists
+        """
+        return self._todo
+
 
 class Rebaser:
     """Handles git rebase operations."""
@@ -487,7 +833,7 @@ class Rebaser:
         last_commit = self._done[-1]
 
         # Update the branch we're rebasing
-        if hasattr(self, "_rebasing_branch") and self._rebasing_branch:
+        if self._rebasing_branch:
             self.repo.refs[self._rebasing_branch] = last_commit.id
             # If HEAD was pointing to this branch, it will follow automatically
         else:
@@ -567,3 +913,326 @@ def rebase(
 
     # Return the SHAs of the rebased commits
     return [c.id for c in rebaser._done]
+
+
+def start_interactive(
+    repo: Repo,
+    upstream: bytes,
+    onto: Optional[bytes] = None,
+    branch: Optional[bytes] = None,
+    editor_callback=None,
+) -> RebaseTodo:
+    """Start an interactive rebase.
+
+    This function generates a todo list and optionally opens an editor for the user
+    to modify it before starting the rebase.
+
+    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)
+        editor_callback: Optional callback to edit todo content. If None, no editing.
+                        Should take bytes and return bytes.
+
+    Returns:
+        RebaseTodo object with the (possibly edited) todo list
+
+    Raises:
+        RebaseError: If rebase cannot be started
+    """
+    rebaser = Rebaser(repo)
+
+    # Get commits to rebase
+    commits = rebaser.start(upstream, onto, branch)
+
+    if not commits:
+        raise RebaseError("No commits to rebase")
+
+    # Generate todo list
+    todo = RebaseTodo.from_commits(commits)
+
+    # Save initial todo to disk
+    state_manager = repo.get_rebase_state_manager()
+    state_manager.save_todo(todo)
+
+    # Let user edit todo if callback provided
+    if editor_callback:
+        todo_content = todo.to_string().encode("utf-8")
+        edited_content = editor_callback(todo_content)
+
+        # Parse edited todo
+        edited_todo = RebaseTodo.from_string(
+            edited_content.decode("utf-8", errors="replace")
+        )
+
+        # Check if user removed all entries (abort)
+        if not edited_todo.entries:
+            # User removed everything, abort
+            rebaser.abort()
+            raise RebaseAbort("Rebase aborted - empty todo list")
+
+        todo = edited_todo
+
+        # Save edited todo
+        state_manager.save_todo(todo)
+
+    return todo
+
+
+def edit_todo(repo: Repo, editor_callback) -> RebaseTodo:
+    """Edit the todo list of an in-progress interactive rebase.
+
+    Args:
+        repo: Repository with in-progress rebase
+        editor_callback: Callback to edit todo content. Takes bytes, returns bytes.
+
+    Returns:
+        Updated RebaseTodo object
+
+    Raises:
+        RebaseError: If no rebase is in progress or todo cannot be loaded
+    """
+    state_manager = repo.get_rebase_state_manager()
+
+    if not state_manager.exists():
+        raise RebaseError("No rebase in progress")
+
+    # Load current todo
+    todo = state_manager.load_todo()
+    if not todo:
+        raise RebaseError("No interactive rebase in progress")
+
+    # Edit todo
+    todo_content = todo.to_string().encode("utf-8")
+    edited_content = editor_callback(todo_content)
+
+    # Parse edited todo
+    edited_todo = RebaseTodo.from_string(
+        edited_content.decode("utf-8", errors="replace")
+    )
+
+    # Save edited todo
+    state_manager.save_todo(edited_todo)
+
+    return edited_todo
+
+
+def process_interactive_rebase(
+    repo: Repo,
+    todo: Optional[RebaseTodo] = None,
+    editor_callback=None,
+) -> tuple[bool, Optional[str]]:
+    """Process an interactive rebase.
+
+    This function executes the commands in the todo list sequentially.
+
+    Args:
+        repo: Repository to rebase in
+        todo: RebaseTodo object (if None, loads from state)
+        editor_callback: Optional callback for reword operations
+
+    Returns:
+        Tuple of (is_complete, pause_reason)
+        - is_complete: True if rebase is complete, False if paused
+        - pause_reason: Reason for pause (e.g., "edit", "conflict", "break") or None
+
+    Raises:
+        RebaseError: If rebase fails
+    """
+    state_manager = repo.get_rebase_state_manager()
+    rebaser = Rebaser(repo)
+
+    # Load todo if not provided
+    if todo is None:
+        todo = state_manager.load_todo()
+        if not todo:
+            raise RebaseError("No interactive rebase in progress")
+
+    # Process each todo entry
+    while not todo.is_complete():
+        entry = todo.get_current()
+        if not entry:
+            break
+
+        # Handle each command type
+        if entry.command == RebaseTodoCommand.PICK:
+            # Regular cherry-pick
+            result = rebaser.continue_()
+            if result is not None:
+                # Conflicts
+                return False, "conflict"
+
+        elif entry.command == RebaseTodoCommand.REWORD:
+            # Cherry-pick then edit message
+            result = rebaser.continue_()
+            if result is not None:
+                # Conflicts
+                return False, "conflict"
+
+            # Get the last commit and allow editing its message
+            if rebaser._done and editor_callback:
+                last_commit = rebaser._done[-1]
+                new_message = editor_callback(last_commit.message)
+
+                # Create new commit with edited message
+                new_commit = Commit()
+                new_commit.tree = last_commit.tree
+                new_commit.parents = last_commit.parents
+                new_commit.author = last_commit.author
+                new_commit.author_time = last_commit.author_time
+                new_commit.author_timezone = last_commit.author_timezone
+                new_commit.committer = last_commit.committer
+                new_commit.commit_time = last_commit.commit_time
+                new_commit.commit_timezone = last_commit.commit_timezone
+                new_commit.message = new_message
+                new_commit.encoding = last_commit.encoding
+
+                repo.object_store.add_object(new_commit)
+
+                # Replace last commit in done list
+                rebaser._done[-1] = new_commit
+
+        elif entry.command == RebaseTodoCommand.EDIT:
+            # Cherry-pick then pause
+            result = rebaser.continue_()
+            if result is not None:
+                # Conflicts
+                return False, "conflict"
+
+            # Pause for user to amend
+            todo.advance()
+            state_manager.save_todo(todo)
+            return False, "edit"
+
+        elif entry.command == RebaseTodoCommand.SQUASH:
+            # Combine with previous commit, keeping both messages
+            if not rebaser._done:
+                raise RebaseError("Cannot squash without a previous commit")
+
+            conflict_result = _squash_commits(
+                repo, rebaser, entry, keep_message=True, editor_callback=editor_callback
+            )
+            if conflict_result == "conflict":
+                return False, "conflict"
+
+        elif entry.command == RebaseTodoCommand.FIXUP:
+            # Combine with previous commit, discarding this message
+            if not rebaser._done:
+                raise RebaseError("Cannot fixup without a previous commit")
+
+            conflict_result = _squash_commits(
+                repo, rebaser, entry, keep_message=False, editor_callback=None
+            )
+            if conflict_result == "conflict":
+                return False, "conflict"
+
+        elif entry.command == RebaseTodoCommand.DROP:
+            # Skip this commit
+            if rebaser._todo:
+                rebaser._todo.pop(0)
+
+        elif entry.command == RebaseTodoCommand.EXEC:
+            # Execute shell command
+            if entry.arguments:
+                try:
+                    subprocess.run(entry.arguments, shell=True, check=True)
+                except subprocess.CalledProcessError as e:
+                    # Command failed, pause rebase
+                    return False, f"exec failed: {e}"
+
+        elif entry.command == RebaseTodoCommand.BREAK:
+            # Pause rebase
+            todo.advance()
+            state_manager.save_todo(todo)
+            return False, "break"
+
+        else:
+            # Unsupported command
+            raise RebaseError(f"Unsupported rebase command: {entry.command.value}")
+
+        # Move to next entry
+        todo.advance()
+
+        # Save progress
+        state_manager.save_todo(todo)
+        rebaser._save_rebase_state()
+
+    # Rebase complete
+    rebaser._finish_rebase()
+    return True, None
+
+
+def _squash_commits(
+    repo: Repo,
+    rebaser: Rebaser,
+    entry: RebaseTodoEntry,
+    keep_message: bool,
+    editor_callback=None,
+) -> Optional[str]:
+    """Helper to squash/fixup commits.
+
+    Args:
+        repo: Repository
+        rebaser: Rebaser instance
+        entry: Todo entry for the commit to squash
+        keep_message: Whether to keep this commit's message (squash) or discard (fixup)
+        editor_callback: Optional callback to edit combined message (for squash)
+
+    Returns:
+        None on success, "conflict" on conflict
+    """
+    if not rebaser._done:
+        raise RebaseError("Cannot squash without a previous commit")
+
+    # Get the commit to squash
+    if not entry.commit_sha:
+        raise RebaseError("No commit SHA for squash/fixup operation")
+    commit_to_squash = repo[entry.commit_sha]
+
+    # Get the previous commit (target of squash)
+    previous_commit = rebaser._done[-1]
+
+    # Cherry-pick the changes onto the previous commit
+    parent = repo[commit_to_squash.parents[0]]
+
+    # Perform three-way merge for the tree
+    merged_tree, conflicts = three_way_merge(
+        repo.object_store, parent, previous_commit, commit_to_squash
+    )
+
+    if conflicts:
+        return "conflict"
+
+    # Combine messages if squashing (not fixup)
+    if keep_message:
+        combined_message = previous_commit.message + b"\n\n" + commit_to_squash.message
+        if editor_callback:
+            combined_message = editor_callback(combined_message)
+    else:
+        combined_message = previous_commit.message
+
+    # Create new combined commit
+    new_commit = Commit()
+    new_commit.tree = merged_tree.id
+    new_commit.parents = previous_commit.parents
+    new_commit.author = previous_commit.author
+    new_commit.author_time = previous_commit.author_time
+    new_commit.author_timezone = previous_commit.author_timezone
+    new_commit.committer = commit_to_squash.committer
+    new_commit.commit_time = commit_to_squash.commit_time
+    new_commit.commit_timezone = commit_to_squash.commit_timezone
+    new_commit.message = combined_message
+    new_commit.encoding = previous_commit.encoding
+
+    repo.object_store.add_object(merged_tree)
+    repo.object_store.add_object(new_commit)
+
+    # Replace the previous commit with the combined one
+    rebaser._done[-1] = new_commit
+
+    # Remove the squashed commit from todo
+    if rebaser._todo and rebaser._todo[0].id == commit_to_squash.id:
+        rebaser._todo.pop(0)
+
+    return None

+ 298 - 1
tests/test_rebase.py

@@ -25,7 +25,16 @@ import os
 import tempfile
 
 from dulwich.objects import Blob, Commit, Tree
-from dulwich.rebase import RebaseConflict, Rebaser, rebase
+from dulwich.rebase import (
+    RebaseConflict,
+    Rebaser,
+    RebaseTodo,
+    RebaseTodoCommand,
+    RebaseTodoEntry,
+    process_interactive_rebase,
+    rebase,
+    start_interactive,
+)
 from dulwich.repo import MemoryRepo, Repo
 from dulwich.tests.utils import make_commit
 
@@ -454,3 +463,291 @@ class RebasePorcelainTestCase(TestCase):
         self.assertIn(b"feature.txt", tree)
         self.assertIn(b"main.txt", tree)
         self.assertIn(b"README.md", tree)
+
+
+class InteractiveRebaseTestCase(TestCase):
+    """Tests for interactive rebase functionality."""
+
+    def setUp(self):
+        """Set up test repository."""
+        super().setUp()
+        self.repo = MemoryRepo()
+        self._setup_initial_commit()
+
+    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 _create_test_commits(self):
+        """Create a series of test commits for interactive rebase."""
+        commits = []
+        parent = self.initial_commit.id
+
+        for i in range(3):
+            blob = Blob.from_string(f"Content {i}\n".encode())
+            self.repo.object_store.add_object(blob)
+
+            tree = Tree()
+            tree.add(f"file{i}.txt".encode(), 0o100644, blob.id)
+            self.repo.object_store.add_object(tree)
+
+            commit = Commit()
+            commit.tree = tree.id
+            commit.parents = [parent]
+            commit.message = f"Commit {i}".encode()
+            commit.committer = b"Test User <test@example.com>"
+            commit.author = b"Test User <test@example.com>"
+            commit.commit_time = 1000000 + i * 100
+            commit.author_time = 1000000 + i * 100
+            commit.commit_timezone = 0
+            commit.author_timezone = 0
+            self.repo.object_store.add_object(commit)
+
+            commits.append(commit)
+            parent = commit.id
+
+        self.repo.refs[b"refs/heads/feature"] = commits[-1].id
+        return commits
+
+    def test_todo_parsing(self):
+        """Test parsing of todo file format."""
+        todo_content = """pick 1234567 First commit
+reword 2345678 Second commit
+edit 3456789 Third commit
+squash 4567890 Fourth commit
+fixup 5678901 Fifth commit
+drop 6789012 Sixth commit
+exec echo "Running test"
+break
+# This is a comment
+"""
+        todo = RebaseTodo.from_string(todo_content)
+
+        self.assertEqual(len(todo.entries), 8)
+
+        # Check first entry
+        self.assertEqual(todo.entries[0].command, RebaseTodoCommand.PICK)
+        self.assertEqual(todo.entries[0].commit_sha, b"1234567")
+        self.assertEqual(todo.entries[0].short_message, "First commit")
+
+        # Check reword
+        self.assertEqual(todo.entries[1].command, RebaseTodoCommand.REWORD)
+
+        # Check exec
+        self.assertEqual(todo.entries[6].command, RebaseTodoCommand.EXEC)
+        self.assertEqual(todo.entries[6].arguments, 'echo "Running test"')
+
+        # Check break
+        self.assertEqual(todo.entries[7].command, RebaseTodoCommand.BREAK)
+
+    def test_todo_generation(self):
+        """Test generation of todo list from commits."""
+        commits = self._create_test_commits()
+        todo = RebaseTodo.from_commits(commits)
+
+        # Should have one pick entry per commit
+        self.assertEqual(len(todo.entries), 3)
+
+        for i, entry in enumerate(todo.entries):
+            self.assertEqual(entry.command, RebaseTodoCommand.PICK)
+            # commit_sha stores the full hex SHA as bytes
+            self.assertEqual(entry.commit_sha, commits[i].id)
+            self.assertIn(f"Commit {i}", entry.short_message)
+
+    def test_todo_serialization(self):
+        """Test serialization of todo list."""
+        entries = [
+            RebaseTodoEntry(
+                command=RebaseTodoCommand.PICK,
+                commit_sha=b"1234567890abcdef",
+                short_message="First commit",
+            ),
+            RebaseTodoEntry(
+                command=RebaseTodoCommand.SQUASH,
+                commit_sha=b"fedcba0987654321",
+                short_message="Second commit",
+            ),
+            RebaseTodoEntry(command=RebaseTodoCommand.EXEC, arguments="make test"),
+        ]
+
+        todo = RebaseTodo(entries)
+        content = todo.to_string(include_comments=False)
+
+        lines = content.strip().split("\n")
+        self.assertEqual(len(lines), 3)
+        self.assertIn("pick 1234567", lines[0])
+        self.assertIn("squash fedcba0", lines[1])
+        self.assertIn("exec make test", lines[2])
+
+    def test_start_interactive_no_editor(self):
+        """Test starting interactive rebase without editor."""
+        self._create_test_commits()
+
+        # Start interactive rebase
+        todo = start_interactive(
+            self.repo,
+            b"refs/heads/master",
+            branch=b"refs/heads/feature",
+            editor_callback=None,
+        )
+
+        # Should have generated todo list
+        self.assertEqual(len(todo.entries), 3)
+        for entry in todo.entries:
+            self.assertEqual(entry.command, RebaseTodoCommand.PICK)
+
+    def test_start_interactive_with_editor(self):
+        """Test starting interactive rebase with editor callback."""
+        self._create_test_commits()
+
+        def mock_editor(content):
+            # Simulate user changing pick to squash for second commit
+            lines = content.decode().splitlines()
+            new_lines = []
+            for i, line in enumerate(lines):
+                if i == 1 and line.startswith("pick"):
+                    new_lines.append(line.replace("pick", "squash"))
+                else:
+                    new_lines.append(line)
+            return "\n".join(new_lines).encode()
+
+        todo = start_interactive(
+            self.repo,
+            b"refs/heads/master",
+            branch=b"refs/heads/feature",
+            editor_callback=mock_editor,
+        )
+
+        # Second entry should be squash
+        self.assertEqual(todo.entries[0].command, RebaseTodoCommand.PICK)
+        self.assertEqual(todo.entries[1].command, RebaseTodoCommand.SQUASH)
+        self.assertEqual(todo.entries[2].command, RebaseTodoCommand.PICK)
+
+    def test_process_drop_command(self):
+        """Test processing DROP command in interactive rebase."""
+        commits = self._create_test_commits()
+
+        # Create todo with drop command
+        entries = [
+            RebaseTodoEntry(
+                command=RebaseTodoCommand.PICK,
+                commit_sha=commits[0].id,
+                short_message="Commit 0",
+            ),
+            RebaseTodoEntry(
+                command=RebaseTodoCommand.DROP,
+                commit_sha=commits[1].id,
+                short_message="Commit 1",
+            ),
+            RebaseTodoEntry(
+                command=RebaseTodoCommand.PICK,
+                commit_sha=commits[2].id,
+                short_message="Commit 2",
+            ),
+        ]
+
+        todo = RebaseTodo(entries)
+        is_complete, pause_reason = process_interactive_rebase(self.repo, todo)
+
+        # Should complete successfully
+        self.assertTrue(is_complete)
+        self.assertIsNone(pause_reason)
+
+        # Should have only picked 2 commits (dropped one)
+        # Note: _done list would contain the rebased commits
+
+    def test_process_break_command(self):
+        """Test processing BREAK command in interactive rebase."""
+        commits = self._create_test_commits()
+
+        entries = [
+            RebaseTodoEntry(
+                command=RebaseTodoCommand.PICK,
+                commit_sha=commits[0].id,
+                short_message="Commit 0",
+            ),
+            RebaseTodoEntry(command=RebaseTodoCommand.BREAK),
+            RebaseTodoEntry(
+                command=RebaseTodoCommand.PICK,
+                commit_sha=commits[1].id,
+                short_message="Commit 1",
+            ),
+        ]
+
+        todo = RebaseTodo(entries)
+        is_complete, pause_reason = process_interactive_rebase(self.repo, todo)
+
+        # Should pause at break
+        self.assertFalse(is_complete)
+        self.assertEqual(pause_reason, "break")
+
+        # Todo should be at position after break
+        self.assertEqual(todo.current_index, 2)
+
+    def test_process_edit_command(self):
+        """Test processing EDIT command in interactive rebase."""
+        commits = self._create_test_commits()
+
+        entries = [
+            RebaseTodoEntry(
+                command=RebaseTodoCommand.PICK,
+                commit_sha=commits[0].id,
+                short_message="Commit 0",
+            ),
+            RebaseTodoEntry(
+                command=RebaseTodoCommand.EDIT,
+                commit_sha=commits[1].id,
+                short_message="Commit 1",
+            ),
+        ]
+
+        todo = RebaseTodo(entries)
+        is_complete, pause_reason = process_interactive_rebase(self.repo, todo)
+
+        # Should pause for editing
+        self.assertFalse(is_complete)
+        self.assertEqual(pause_reason, "edit")
+
+    def test_abbreviations(self):
+        """Test parsing abbreviated commands."""
+        todo_content = """p 1234567 Pick
+r 2345678 Reword
+e 3456789 Edit
+s 4567890 Squash
+f 5678901 Fixup
+d 6789012 Drop
+x echo test
+b
+"""
+        todo = RebaseTodo.from_string(todo_content)
+
+        self.assertEqual(todo.entries[0].command, RebaseTodoCommand.PICK)
+        self.assertEqual(todo.entries[1].command, RebaseTodoCommand.REWORD)
+        self.assertEqual(todo.entries[2].command, RebaseTodoCommand.EDIT)
+        self.assertEqual(todo.entries[3].command, RebaseTodoCommand.SQUASH)
+        self.assertEqual(todo.entries[4].command, RebaseTodoCommand.FIXUP)
+        self.assertEqual(todo.entries[5].command, RebaseTodoCommand.DROP)
+        self.assertEqual(todo.entries[6].command, RebaseTodoCommand.EXEC)
+        self.assertEqual(todo.entries[7].command, RebaseTodoCommand.BREAK)