Jelmer Vernooij преди 2 месеца
родител
ревизия
894e6dd6f0
променени са 6 файла, в които са добавени 1480 реда и са изтрити 0 реда
  1. 4 0
      NEWS
  2. 86 0
      dulwich/cli.py
  3. 144 0
      dulwich/porcelain.py
  4. 525 0
      dulwich/rerere.py
  5. 1 0
      tests/__init__.py
  6. 720 0
      tests/test_rerere.py

+ 4 - 0
NEWS

@@ -2,6 +2,10 @@
 
  * Drop support for Python 3.9. (Jelmer Vernooij)
 
+ * Add support for ``git rerere`` (reuse recorded resolution) with CLI
+   subcommands and porcelain functions. Supports ``rerere.enabled`` and
+   ``rerere.autoupdate`` configuration. (Jelmer Vernooij, #1786)
+
  * Add support for ``git mailinfo`` command to extract patch information from
    email messages. Implements ``dulwich mailinfo`` CLI command,
    ``porcelain.mailinfo()``, and ``patch.mailinfo()`` with support for subject

+ 86 - 0
dulwich/cli.py

@@ -6407,6 +6407,91 @@ class cmd_worktree(SuperCommand):
     default_command = cmd_worktree_list
 
 
+class cmd_rerere(Command):
+    """Record and reuse recorded conflict resolutions."""
+
+    def run(self, args: Sequence[str]) -> None:
+        """Execute the rerere command.
+
+        Args:
+            args: Command line arguments
+        """
+        parser = argparse.ArgumentParser()
+        parser.add_argument("gitdir", nargs="?", default=".", help="Git directory")
+        parser.add_argument(
+            "subcommand",
+            nargs="?",
+            default=None,
+            choices=["status", "diff", "forget", "clear", "gc"],
+            help="Subcommand to execute (default: record conflicts)",
+        )
+        parser.add_argument(
+            "pathspec", nargs="?", help="Path specification (for forget subcommand)"
+        )
+        parser.add_argument(
+            "--max-age-days",
+            type=int,
+            default=60,
+            help="Maximum age in days for gc (default: 60)",
+        )
+        parsed_args = parser.parse_args(args)
+
+        if parsed_args.subcommand is None:
+            # Record current conflicts
+            recorded, resolved = porcelain.rerere(parsed_args.gitdir)
+            if not recorded:
+                sys.stdout.write("No conflicts to record.\n")
+            else:
+                for path, conflict_id in recorded:
+                    sys.stdout.write(
+                        f"Recorded resolution for {path.decode('utf-8')}: {conflict_id}\n"
+                    )
+                if resolved:
+                    sys.stdout.write("\nAutomatically resolved:\n")
+                    for path in resolved:
+                        sys.stdout.write(f"  {path.decode('utf-8')}\n")
+
+        elif parsed_args.subcommand == "status":
+            status_list = porcelain.rerere_status(parsed_args.gitdir)
+            if not status_list:
+                sys.stdout.write("No recorded resolutions.\n")
+            else:
+                for conflict_id, has_resolution in status_list:
+                    status = "resolved" if has_resolution else "unresolved"
+                    sys.stdout.write(f"{conflict_id}\t{status}\n")
+
+        elif parsed_args.subcommand == "diff":
+            diff_list = porcelain.rerere_diff(parsed_args.gitdir)
+            if not diff_list:
+                sys.stdout.write("No recorded conflicts.\n")
+            else:
+                for conflict_id, preimage, postimage in diff_list:
+                    sys.stdout.write(f"--- {conflict_id} (preimage)\n")
+                    sys.stdout.buffer.write(preimage)
+                    sys.stdout.write("\n")
+                    if postimage:
+                        sys.stdout.write(f"+++ {conflict_id} (postimage)\n")
+                        sys.stdout.buffer.write(postimage)
+                        sys.stdout.write("\n")
+
+        elif parsed_args.subcommand == "forget":
+            porcelain.rerere_forget(parsed_args.gitdir, parsed_args.pathspec)
+            if parsed_args.pathspec:
+                sys.stdout.write(f"Forgot resolution for {parsed_args.pathspec}\n")
+            else:
+                sys.stdout.write("Forgot all resolutions\n")
+
+        elif parsed_args.subcommand == "clear":
+            porcelain.rerere_clear(parsed_args.gitdir)
+            sys.stdout.write("Cleared all rerere resolutions\n")
+
+        elif parsed_args.subcommand == "gc":
+            porcelain.rerere_gc(parsed_args.gitdir, parsed_args.max_age_days)
+            sys.stdout.write(
+                f"Cleaned up resolutions older than {parsed_args.max_age_days} days\n"
+            )
+
+
 commands = {
     "add": cmd_add,
     "annotate": cmd_annotate,
@@ -6464,6 +6549,7 @@ commands = {
     "rebase": cmd_rebase,
     "receive-pack": cmd_receive_pack,
     "reflog": cmd_reflog,
+    "rerere": cmd_rerere,
     "remote": cmd_remote,
     "repack": cmd_repack,
     "replace": cmd_replace,

+ 144 - 0
dulwich/porcelain.py

@@ -56,6 +56,7 @@ Currently implemented:
  * remote{_add}
  * receive_pack
  * replace{_create,_delete,_list}
+ * rerere{_status,_diff,_forget,_clear,_gc}
  * reset
  * revert
  * sparse_checkout
@@ -8825,3 +8826,146 @@ def mailinfo(
             f.write(result.patch)
 
     return result
+
+
+def rerere(repo: RepoPath = ".") -> tuple[list[tuple[bytes, str]], list[bytes]]:
+    """Record current conflict resolutions and apply known resolutions.
+
+    This reads conflicted files from the working tree and records them
+    in the rerere cache. If rerere.autoupdate is enabled and a known
+    resolution exists, it will be automatically applied.
+
+    Args:
+        repo: Path to the repository
+
+    Returns:
+        Tuple of:
+        - List of tuples (path, conflict_id) for recorded conflicts
+        - List of paths where resolutions were automatically applied
+    """
+    from dulwich.rerere import _has_conflict_markers, rerere_auto
+
+    with open_repo_closing(repo) as r:
+        # Get conflicts from the index (if available)
+        index = r.open_index()
+        conflicts = []
+
+        from dulwich.index import ConflictedIndexEntry
+
+        for path, entry in index.items():
+            if isinstance(entry, ConflictedIndexEntry):
+                conflicts.append(path)
+
+        # Also scan working tree for files with conflict markers
+        # This is needed because merge() doesn't always create ConflictedIndexEntry
+        if not conflicts:
+            working_tree = r.path
+            for path in index:
+                file_path = os.path.join(working_tree, os.fsdecode(path))
+                try:
+                    with open(file_path, "rb") as f:
+                        content = f.read()
+                    if _has_conflict_markers(content):
+                        conflicts.append(path)
+                except (FileNotFoundError, IsADirectoryError, PermissionError):
+                    pass
+
+        # Record conflicts and apply known resolutions
+        working_tree = r.path
+        return rerere_auto(r, working_tree, conflicts)
+
+
+def rerere_status(repo: RepoPath = ".") -> list[tuple[str, bool]]:
+    """Get the status of all conflicts in the rerere cache.
+
+    Args:
+        repo: Path to the repository
+
+    Returns:
+        List of tuples (conflict_id, has_resolution)
+    """
+    from dulwich.rerere import RerereCache
+
+    with open_repo_closing(repo) as r:
+        cache = RerereCache.from_repo(r)
+        return cache.status()
+
+
+def rerere_diff(
+    repo: RepoPath = ".", conflict_id: str | None = None
+) -> list[tuple[str, bytes, bytes | None]]:
+    """Show differences for recorded rerere conflicts.
+
+    Args:
+        repo: Path to the repository
+        conflict_id: Optional specific conflict ID to show
+
+    Returns:
+        List of tuples (conflict_id, preimage, postimage)
+    """
+    from dulwich.rerere import RerereCache
+
+    with open_repo_closing(repo) as r:
+        cache = RerereCache.from_repo(r)
+
+        if conflict_id:
+            preimage, postimage = cache.diff(conflict_id)
+            if preimage is not None:
+                return [(conflict_id, preimage, postimage)]
+            return []
+
+        # Show all conflicts
+        results = []
+        for cid, _has_res in cache.status():
+            preimage, postimage = cache.diff(cid)
+            if preimage is not None:
+                results.append((cid, preimage, postimage))
+        return results
+
+
+def rerere_forget(repo: RepoPath = ".", pathspec: str | bytes | None = None) -> None:
+    """Forget recorded rerere resolutions for a pathspec.
+
+    Args:
+        repo: Path to the repository
+        pathspec: Path to forget (currently not implemented, forgets all)
+    """
+    from dulwich.rerere import RerereCache
+
+    with open_repo_closing(repo) as r:
+        cache = RerereCache.from_repo(r)
+
+        if pathspec:
+            # TODO: Implement pathspec matching
+            # For now, we need to track which conflict IDs correspond to which paths
+            raise NotImplementedError("Pathspec matching not yet implemented")
+
+        # Forget all conflicts (this is when called with no pathspec after resolving)
+        cache.clear()
+
+
+def rerere_clear(repo: RepoPath = ".") -> None:
+    """Clear all recorded rerere resolutions.
+
+    Args:
+        repo: Path to the repository
+    """
+    from dulwich.rerere import RerereCache
+
+    with open_repo_closing(repo) as r:
+        cache = RerereCache.from_repo(r)
+        cache.clear()
+
+
+def rerere_gc(repo: RepoPath = ".", max_age_days: int = 60) -> None:
+    """Garbage collect old rerere resolutions.
+
+    Args:
+        repo: Path to the repository
+        max_age_days: Maximum age in days for keeping resolutions
+    """
+    from dulwich.rerere import RerereCache
+
+    with open_repo_closing(repo) as r:
+        cache = RerereCache.from_repo(r)
+        cache.gc(max_age_days)

+ 525 - 0
dulwich/rerere.py

@@ -0,0 +1,525 @@
+"""Git rerere (reuse recorded resolution) implementation.
+
+This module implements Git's rerere functionality, which records and reuses
+manual conflict resolutions. When a merge conflict occurs, rerere records the
+conflict and the resolution. If the same conflict happens again, rerere can
+automatically apply the recorded resolution.
+"""
+
+import hashlib
+import os
+import time
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+    from dulwich.config import StackedConfig
+    from dulwich.repo import Repo
+
+
+def _compute_conflict_id(preimage: bytes) -> str:
+    """Compute a unique ID for a conflict based on its preimage.
+
+    The preimage is the conflict with all conflict markers normalized
+    to a canonical form (without side names).
+
+    Args:
+        preimage: The normalized conflict content
+
+    Returns:
+        Hex digest of the SHA-1 hash
+    """
+    return hashlib.sha1(preimage).hexdigest()
+
+
+def _normalize_conflict_markers(content: bytes) -> bytes:
+    """Normalize conflict markers to a canonical form.
+
+    This removes the side names (ours/theirs) from conflict markers
+    to create a preimage that only depends on the structure of the conflict.
+
+    Args:
+        content: File content with conflict markers
+
+    Returns:
+        Normalized content with generic conflict markers
+    """
+    lines = content.split(b"\n")
+    result = []
+
+    for line in lines:
+        # Normalize conflict start markers
+        if line.startswith(b"<<<<<<<"):
+            result.append(b"<<<<<<<")
+        # Normalize conflict end markers
+        elif line.startswith(b">>>>>>>"):
+            result.append(b">>>>>>>")
+        # Keep separator as is
+        elif line == b"=======":
+            result.append(line)
+        else:
+            result.append(line)
+
+    return b"\n".join(result)
+
+
+def _extract_conflict_regions(content: bytes) -> list[tuple[bytes, bytes, bytes]]:
+    """Extract conflict regions from file content.
+
+    Args:
+        content: File content with conflict markers
+
+    Returns:
+        List of tuples (ours, separator, theirs) for each conflict region
+    """
+    conflicts = []
+    lines = content.split(b"\n")
+    i = 0
+
+    while i < len(lines):
+        if lines[i].startswith(b"<<<<<<<"):
+            # Found conflict start
+            ours_lines = []
+            theirs_lines = []
+            i += 1
+
+            # Collect "ours" lines
+            while i < len(lines) and not lines[i].startswith(b"======="):
+                ours_lines.append(lines[i])
+                i += 1
+
+            if i >= len(lines):
+                break
+
+            # Skip separator
+            i += 1
+
+            # Collect "theirs" lines
+            while i < len(lines) and not lines[i].startswith(b">>>>>>>"):
+                theirs_lines.append(lines[i])
+                i += 1
+
+            if i >= len(lines):
+                break
+
+            ours = b"\n".join(ours_lines)
+            theirs = b"\n".join(theirs_lines)
+            conflicts.append((ours, b"=======", theirs))
+
+        i += 1
+
+    return conflicts
+
+
+def _has_conflict_markers(content: bytes) -> bool:
+    """Check if content contains conflict markers.
+
+    Args:
+        content: File content to check
+
+    Returns:
+        True if conflict markers are present
+    """
+    return b"<<<<<<<" in content and b"=======" in content and b">>>>>>>" in content
+
+
+def _remove_conflict_markers(content: bytes) -> bytes:
+    """Remove all conflict markers from resolved content.
+
+    This is used to extract the resolution from a file that has been
+    manually resolved by the user.
+
+    Args:
+        content: Resolved file content
+
+    Returns:
+        Content with conflict markers removed
+    """
+    lines = content.split(b"\n")
+    result = []
+    in_conflict = False
+
+    for line in lines:
+        if line.startswith(b"<<<<<<<"):
+            in_conflict = True
+            continue
+        elif line.startswith(b">>>>>>>"):
+            in_conflict = False
+            continue
+        elif line == b"=======":
+            continue
+
+        if not in_conflict:
+            result.append(line)
+
+    return b"\n".join(result)
+
+
+class RerereCache:
+    """Manages the rerere cache in .git/rr-cache/."""
+
+    def __init__(self, rr_cache_dir: bytes | str) -> None:
+        """Initialize RerereCache.
+
+        Args:
+            rr_cache_dir: Path to the .git/rr-cache directory
+        """
+        if isinstance(rr_cache_dir, bytes):
+            self.rr_cache_dir = os.fsdecode(rr_cache_dir)
+        else:
+            self.rr_cache_dir = rr_cache_dir
+
+    @classmethod
+    def from_repo(cls, repo: "Repo") -> "RerereCache":
+        """Create a RerereCache from a repository.
+
+        Args:
+            repo: A Dulwich repository object
+
+        Returns:
+            RerereCache instance for the repository
+        """
+        rr_cache_dir = os.path.join(repo.controldir(), "rr-cache")
+        return cls(rr_cache_dir)
+
+    def _ensure_cache_dir(self) -> None:
+        """Ensure the rr-cache directory exists."""
+        os.makedirs(self.rr_cache_dir, exist_ok=True)
+
+    def _get_conflict_dir(self, conflict_id: str) -> str:
+        """Get the directory path for a specific conflict.
+
+        Args:
+            conflict_id: The conflict ID (SHA-1 hash)
+
+        Returns:
+            Path to the conflict directory
+        """
+        return os.path.join(self.rr_cache_dir, conflict_id)
+
+    def _get_preimage_path(self, conflict_id: str) -> str:
+        """Get the path to the preimage file for a conflict.
+
+        Args:
+            conflict_id: The conflict ID
+
+        Returns:
+            Path to the preimage file
+        """
+        return os.path.join(self._get_conflict_dir(conflict_id), "preimage")
+
+    def _get_postimage_path(self, conflict_id: str) -> str:
+        """Get the path to the postimage file for a conflict.
+
+        Args:
+            conflict_id: The conflict ID
+
+        Returns:
+            Path to the postimage file
+        """
+        return os.path.join(self._get_conflict_dir(conflict_id), "postimage")
+
+    def record_conflict(self, path: bytes, content: bytes) -> str | None:
+        """Record a conflict in the rerere cache.
+
+        Args:
+            path: Path to the conflicted file
+            content: File content with conflict markers
+
+        Returns:
+            The conflict ID if recorded, None if no conflict markers found
+        """
+        if not _has_conflict_markers(content):
+            return None
+
+        self._ensure_cache_dir()
+
+        # Normalize conflict markers and compute ID
+        preimage = _normalize_conflict_markers(content)
+        conflict_id = _compute_conflict_id(preimage)
+
+        # Create conflict directory
+        conflict_dir = self._get_conflict_dir(conflict_id)
+        os.makedirs(conflict_dir, exist_ok=True)
+
+        # Write preimage
+        preimage_path = self._get_preimage_path(conflict_id)
+        with open(preimage_path, "wb") as f:
+            f.write(content)
+
+        return conflict_id
+
+    def record_resolution(self, conflict_id: str, content: bytes) -> None:
+        """Record a resolution for a previously recorded conflict.
+
+        Args:
+            conflict_id: The conflict ID to record resolution for
+            content: Resolved file content (without conflict markers)
+        """
+        # Write postimage
+        postimage_path = self._get_postimage_path(conflict_id)
+
+        # Ensure directory exists
+        conflict_dir = self._get_conflict_dir(conflict_id)
+        os.makedirs(conflict_dir, exist_ok=True)
+
+        with open(postimage_path, "wb") as f:
+            f.write(content)
+
+    def has_resolution(self, conflict_id: str) -> bool:
+        """Check if a resolution exists for a conflict.
+
+        Args:
+            conflict_id: The conflict ID
+
+        Returns:
+            True if a postimage exists for this conflict
+        """
+        postimage_path = self._get_postimage_path(conflict_id)
+        return os.path.exists(postimage_path)
+
+    def get_resolution(self, conflict_id: str) -> bytes | None:
+        """Get the recorded resolution for a conflict.
+
+        Args:
+            conflict_id: The conflict ID
+
+        Returns:
+            The resolution content, or None if not found
+        """
+        postimage_path = self._get_postimage_path(conflict_id)
+
+        try:
+            with open(postimage_path, "rb") as f:
+                return f.read()
+        except FileNotFoundError:
+            return None
+
+    def apply_resolution(self, conflict_id: str, content: bytes) -> bytes | None:
+        """Apply a recorded resolution to current conflict content.
+
+        Args:
+            conflict_id: The conflict ID
+            content: Current file content with conflict markers
+
+        Returns:
+            Resolved content, or None if resolution couldn't be applied
+        """
+        resolution = self.get_resolution(conflict_id)
+        if resolution is None:
+            return None
+
+        # For now, return the resolution directly
+        # A more sophisticated implementation would merge the resolution
+        # with the current conflict
+        return resolution
+
+    def forget(self, conflict_id: str) -> None:
+        """Forget a recorded conflict and its resolution.
+
+        Args:
+            conflict_id: The conflict ID to forget
+        """
+        conflict_dir = self._get_conflict_dir(conflict_id)
+
+        # Remove preimage and postimage files
+        for filename in ["preimage", "postimage"]:
+            path = os.path.join(conflict_dir, filename)
+            try:
+                os.remove(path)
+            except FileNotFoundError:
+                pass
+
+        # Remove conflict directory if empty
+        try:
+            os.rmdir(conflict_dir)
+        except OSError:
+            pass
+
+    def clear(self) -> None:
+        """Clear all recorded conflicts and resolutions."""
+        if not os.path.exists(self.rr_cache_dir):
+            return
+
+        # Remove all conflict directories
+        for entry in os.listdir(self.rr_cache_dir):
+            conflict_dir = os.path.join(self.rr_cache_dir, entry)
+            if os.path.isdir(conflict_dir):
+                # Remove all files in the directory
+                for filename in os.listdir(conflict_dir):
+                    os.remove(os.path.join(conflict_dir, filename))
+                os.rmdir(conflict_dir)
+
+    def gc(self, max_age_days: int = 60) -> None:
+        """Garbage collect old conflict resolutions.
+
+        Args:
+            max_age_days: Maximum age in days for keeping resolutions
+        """
+        if not os.path.exists(self.rr_cache_dir):
+            return
+
+        cutoff_time = time.time() - (max_age_days * 24 * 60 * 60)
+
+        for entry in os.listdir(self.rr_cache_dir):
+            conflict_dir = os.path.join(self.rr_cache_dir, entry)
+            if not os.path.isdir(conflict_dir):
+                continue
+
+            postimage_path = os.path.join(conflict_dir, "postimage")
+
+            # Only remove if postimage exists and is old
+            if os.path.exists(postimage_path):
+                mtime = os.path.getmtime(postimage_path)
+                if mtime < cutoff_time:
+                    self.forget(entry)
+
+    def status(self) -> list[tuple[str, bool]]:
+        """Get the status of all conflicts in the cache.
+
+        Returns:
+            List of tuples (conflict_id, has_resolution)
+        """
+        if not os.path.exists(self.rr_cache_dir):
+            return []
+
+        result = []
+        for entry in os.listdir(self.rr_cache_dir):
+            conflict_dir = os.path.join(self.rr_cache_dir, entry)
+            if os.path.isdir(conflict_dir):
+                has_res = self.has_resolution(entry)
+                result.append((entry, has_res))
+
+        return sorted(result)
+
+    def diff(self, conflict_id: str) -> tuple[bytes | None, bytes | None]:
+        """Get the preimage and postimage for a conflict.
+
+        Args:
+            conflict_id: The conflict ID
+
+        Returns:
+            Tuple of (preimage, postimage), either may be None
+        """
+        preimage_path = self._get_preimage_path(conflict_id)
+        postimage_path = self._get_postimage_path(conflict_id)
+
+        preimage = None
+        postimage = None
+
+        try:
+            with open(preimage_path, "rb") as f:
+                preimage = f.read()
+        except FileNotFoundError:
+            pass
+
+        try:
+            with open(postimage_path, "rb") as f:
+                postimage = f.read()
+        except FileNotFoundError:
+            pass
+
+        return preimage, postimage
+
+
+def is_rerere_enabled(config: "StackedConfig") -> bool:
+    """Check if rerere is enabled in the config.
+
+    Args:
+        config: Git configuration
+
+    Returns:
+        True if rerere is enabled
+    """
+    try:
+        enabled = config.get((b"rerere",), b"enabled")
+        if enabled is None:
+            return False
+        if isinstance(enabled, bytes):
+            return enabled.lower() in (b"true", b"1", b"yes", b"on")
+        return bool(enabled)
+    except KeyError:
+        return False
+
+
+def is_rerere_autoupdate(config: "StackedConfig") -> bool:
+    """Check if rerere.autoupdate is enabled in the config.
+
+    Args:
+        config: Git configuration
+
+    Returns:
+        True if rerere.autoupdate is enabled
+    """
+    try:
+        autoupdate = config.get((b"rerere",), b"autoupdate")
+        if autoupdate is None:
+            return False
+        if isinstance(autoupdate, bytes):
+            return autoupdate.lower() in (b"true", b"1", b"yes", b"on")
+        return bool(autoupdate)
+    except KeyError:
+        return False
+
+
+def rerere_auto(
+    repo: "Repo",
+    working_tree_path: bytes | str,
+    conflicts: list[bytes],
+) -> tuple[list[tuple[bytes, str]], list[bytes]]:
+    """Automatically record conflicts and apply known resolutions.
+
+    This is the main entry point for rerere integration with merge operations.
+    It should be called after a merge that resulted in conflicts.
+
+    Args:
+        repo: Repository object
+        working_tree_path: Path to the working tree
+        conflicts: List of conflicted file paths
+
+    Returns:
+        Tuple of:
+        - List of tuples (path, conflict_id) for recorded conflicts
+        - List of paths where resolutions were automatically applied
+    """
+    config = repo.get_config_stack()
+    if not is_rerere_enabled(config):
+        return [], []
+
+    cache = RerereCache.from_repo(repo)
+    recorded = []
+    resolved = []
+
+    if isinstance(working_tree_path, bytes):
+        working_tree_path = os.fsdecode(working_tree_path)
+
+    autoupdate = is_rerere_autoupdate(config)
+
+    # Record conflicts from the working tree and apply known resolutions
+    for path in conflicts:
+        # Read the file from the working tree
+        file_path = os.path.join(working_tree_path, os.fsdecode(path))
+
+        try:
+            with open(file_path, "rb") as f:
+                content = f.read()
+        except FileNotFoundError:
+            # File was deleted in conflict
+            continue
+
+        # Record the conflict
+        conflict_id = cache.record_conflict(path, content)
+        if not conflict_id:
+            continue
+
+        recorded.append((path, conflict_id))
+
+        # Check if we have a resolution for this conflict
+        if autoupdate and cache.has_resolution(conflict_id):
+            resolution = cache.get_resolution(conflict_id)
+            if resolution is not None:
+                # Apply the resolution to the working tree
+                with open(file_path, "wb") as f:
+                    f.write(resolution)
+                resolved.append(path)
+
+    return recorded, resolved

+ 1 - 0
tests/__init__.py

@@ -181,6 +181,7 @@ def self_test_suite() -> unittest.TestSuite:
         "refs",
         "reftable",
         "repository",
+        "rerere",
         "server",
         "sparse_patterns",
         "stash",

+ 720 - 0
tests/test_rerere.py

@@ -0,0 +1,720 @@
+"""Tests for rerere functionality."""
+
+import os
+import tempfile
+import unittest
+
+from dulwich.rerere import (
+    RerereCache,
+    _extract_conflict_regions,
+    _has_conflict_markers,
+    _normalize_conflict_markers,
+    _remove_conflict_markers,
+    is_rerere_autoupdate,
+    is_rerere_enabled,
+)
+
+
+class NormalizeConflictMarkersTests(unittest.TestCase):
+    """Tests for _normalize_conflict_markers function."""
+
+    def test_normalize_basic_conflict(self) -> None:
+        """Test normalizing a basic conflict."""
+        content = b"""line 1
+<<<<<<< ours
+our change
+=======
+their change
+>>>>>>> theirs
+line 2
+"""
+        expected = b"""line 1
+<<<<<<<
+our change
+=======
+their change
+>>>>>>>
+line 2
+"""
+        result = _normalize_conflict_markers(content)
+        self.assertEqual(expected, result)
+
+    def test_normalize_with_branch_names(self) -> None:
+        """Test normalizing conflict with branch names."""
+        content = b"""<<<<<<< HEAD
+content from HEAD
+=======
+content from feature
+>>>>>>> feature
+"""
+        expected = b"""<<<<<<<
+content from HEAD
+=======
+content from feature
+>>>>>>>
+"""
+        result = _normalize_conflict_markers(content)
+        self.assertEqual(expected, result)
+
+
+class ExtractConflictRegionsTests(unittest.TestCase):
+    """Tests for _extract_conflict_regions function."""
+
+    def test_extract_single_conflict(self) -> None:
+        """Test extracting a single conflict region."""
+        content = b"""line 1
+<<<<<<< ours
+our change
+=======
+their change
+>>>>>>> theirs
+line 2
+"""
+        regions = _extract_conflict_regions(content)
+        self.assertEqual(1, len(regions))
+        ours, sep, theirs = regions[0]
+        self.assertEqual(b"our change", ours)
+        self.assertEqual(b"=======", sep)
+        self.assertEqual(b"their change", theirs)
+
+    def test_extract_multiple_conflicts(self) -> None:
+        """Test extracting multiple conflict regions."""
+        content = b"""<<<<<<< ours
+change 1
+=======
+change 2
+>>>>>>> theirs
+middle line
+<<<<<<< ours
+change 3
+=======
+change 4
+>>>>>>> theirs
+"""
+        regions = _extract_conflict_regions(content)
+        self.assertEqual(2, len(regions))
+
+
+class HasConflictMarkersTests(unittest.TestCase):
+    """Tests for _has_conflict_markers function."""
+
+    def test_has_conflict_markers(self) -> None:
+        """Test detecting conflict markers."""
+        content = b"""<<<<<<< ours
+our change
+=======
+their change
+>>>>>>> theirs
+"""
+        self.assertTrue(_has_conflict_markers(content))
+
+    def test_no_conflict_markers(self) -> None:
+        """Test content without conflict markers."""
+        content = b"""line 1
+line 2
+line 3
+"""
+        self.assertFalse(_has_conflict_markers(content))
+
+    def test_partial_conflict_markers(self) -> None:
+        """Test content with only some conflict markers."""
+        content = b"""<<<<<<< ours
+our change
+line 3
+"""
+        self.assertFalse(_has_conflict_markers(content))
+
+
+class RemoveConflictMarkersTests(unittest.TestCase):
+    """Tests for _remove_conflict_markers function."""
+
+    def test_remove_conflict_markers(self) -> None:
+        """Test removing conflict markers from resolved content."""
+        content = b"""line 1
+<<<<<<< ours
+our change
+=======
+their change
+>>>>>>> theirs
+line 2
+"""
+        # This is a simplified test - in reality the resolved content
+        # would have the user's chosen resolution
+        result = _remove_conflict_markers(content)
+        # The function keeps only lines outside conflict blocks
+        self.assertNotIn(b"<<<<<<<", result)
+        self.assertNotIn(b"=======", result)
+        self.assertNotIn(b">>>>>>>", result)
+
+
+class RerereCacheTests(unittest.TestCase):
+    """Tests for RerereCache class."""
+
+    def setUp(self) -> None:
+        """Set up test fixtures."""
+        self.tempdir = tempfile.mkdtemp()
+        self.cache = RerereCache(self.tempdir)
+
+    def tearDown(self) -> None:
+        """Clean up test fixtures."""
+        import shutil
+
+        shutil.rmtree(self.tempdir, ignore_errors=True)
+
+    def test_record_conflict(self) -> None:
+        """Test recording a conflict."""
+        content = b"""line 1
+<<<<<<< ours
+our change
+=======
+their change
+>>>>>>> theirs
+line 2
+"""
+        conflict_id = self.cache.record_conflict(b"test.txt", content)
+        self.assertIsNotNone(conflict_id)
+        self.assertEqual(40, len(conflict_id))  # SHA-1 hash length
+
+    def test_record_conflict_no_markers(self) -> None:
+        """Test recording content without conflict markers."""
+        content = b"line 1\nline 2\n"
+        conflict_id = self.cache.record_conflict(b"test.txt", content)
+        self.assertIsNone(conflict_id)
+
+    def test_status_empty(self) -> None:
+        """Test status with no conflicts."""
+        status = self.cache.status()
+        self.assertEqual([], status)
+
+    def test_status_with_conflict(self) -> None:
+        """Test status with a recorded conflict."""
+        content = b"""<<<<<<< ours
+our change
+=======
+their change
+>>>>>>> theirs
+"""
+        conflict_id = self.cache.record_conflict(b"test.txt", content)
+        status = self.cache.status()
+        self.assertEqual(1, len(status))
+        cid, has_resolution = status[0]
+        self.assertEqual(conflict_id, cid)
+        self.assertFalse(has_resolution)
+
+    def test_has_resolution(self) -> None:
+        """Test checking for resolution."""
+        content = b"""<<<<<<< ours
+our change
+=======
+their change
+>>>>>>> theirs
+"""
+        conflict_id = self.cache.record_conflict(b"test.txt", content)
+        self.assertIsNotNone(conflict_id)
+        self.assertFalse(self.cache.has_resolution(conflict_id))
+
+    def test_diff(self) -> None:
+        """Test getting diff for a conflict."""
+        content = b"""<<<<<<< ours
+our change
+=======
+their change
+>>>>>>> theirs
+"""
+        conflict_id = self.cache.record_conflict(b"test.txt", content)
+        self.assertIsNotNone(conflict_id)
+
+        preimage, postimage = self.cache.diff(conflict_id)
+        self.assertIsNotNone(preimage)
+        self.assertIsNone(postimage)  # No resolution recorded yet
+
+    def test_clear(self) -> None:
+        """Test clearing all conflicts."""
+        content = b"""<<<<<<< ours
+our change
+=======
+their change
+>>>>>>> theirs
+"""
+        self.cache.record_conflict(b"test.txt", content)
+        status = self.cache.status()
+        self.assertEqual(1, len(status))
+
+        self.cache.clear()
+        status = self.cache.status()
+        self.assertEqual([], status)
+
+    def test_forget(self) -> None:
+        """Test forgetting a specific conflict."""
+        content = b"""<<<<<<< ours
+our change
+=======
+their change
+>>>>>>> theirs
+"""
+        conflict_id = self.cache.record_conflict(b"test.txt", content)
+        self.assertIsNotNone(conflict_id)
+
+        self.cache.forget(conflict_id)
+        status = self.cache.status()
+        self.assertEqual([], status)
+
+
+class ConfigTests(unittest.TestCase):
+    """Tests for rerere configuration functions."""
+
+    def test_is_rerere_enabled_false_by_default(self) -> None:
+        """Test that rerere is disabled by default."""
+        from dulwich.config import ConfigDict
+
+        config = ConfigDict()
+        self.assertFalse(is_rerere_enabled(config))
+
+    def test_is_rerere_enabled_true(self) -> None:
+        """Test rerere enabled config."""
+        from dulwich.config import ConfigDict
+
+        config = ConfigDict()
+        config.set((b"rerere",), b"enabled", b"true")
+        self.assertTrue(is_rerere_enabled(config))
+
+    def test_is_rerere_autoupdate_false_by_default(self) -> None:
+        """Test that rerere.autoupdate is disabled by default."""
+        from dulwich.config import ConfigDict
+
+        config = ConfigDict()
+        self.assertFalse(is_rerere_autoupdate(config))
+
+    def test_is_rerere_autoupdate_true(self) -> None:
+        """Test rerere.autoupdate enabled config."""
+        from dulwich.config import ConfigDict
+
+        config = ConfigDict()
+        config.set((b"rerere",), b"autoupdate", b"true")
+        self.assertTrue(is_rerere_autoupdate(config))
+
+
+class RerereAutoTests(unittest.TestCase):
+    """Tests for rerere_auto functionality."""
+
+    def setUp(self) -> None:
+        """Set up test fixtures."""
+
+        from dulwich.repo import Repo
+
+        self.tempdir = tempfile.mkdtemp()
+        self.repo = Repo.init(self.tempdir)
+
+        # Enable rerere
+        config = self.repo.get_config()
+        config.set((b"rerere",), b"enabled", b"true")
+        config.write_to_path()
+
+    def tearDown(self) -> None:
+        """Clean up test fixtures."""
+        import shutil
+
+        shutil.rmtree(self.tempdir, ignore_errors=True)
+
+    def test_rerere_auto_disabled(self) -> None:
+        """Test that rerere_auto does nothing when disabled."""
+        from dulwich.rerere import rerere_auto
+
+        # Disable rerere
+        config = self.repo.get_config()
+        config.set((b"rerere",), b"enabled", b"false")
+        config.write_to_path()
+
+        # Create a fake conflicted file
+        conflict_file = os.path.join(self.tempdir, "test.txt")
+        with open(conflict_file, "wb") as f:
+            f.write(
+                b"""<<<<<<< ours
+our change
+=======
+their change
+>>>>>>> theirs
+"""
+            )
+
+        recorded, resolved = rerere_auto(self.repo, self.tempdir, [b"test.txt"])
+        self.assertEqual([], recorded)
+        self.assertEqual([], resolved)
+
+    def test_rerere_auto_records_conflicts(self) -> None:
+        """Test that rerere_auto records conflicts from working tree."""
+        from dulwich.rerere import rerere_auto
+
+        # Create a conflicted file in the working tree
+        conflict_file = os.path.join(self.tempdir, "test.txt")
+        with open(conflict_file, "wb") as f:
+            f.write(
+                b"""line 1
+<<<<<<< ours
+our change
+=======
+their change
+>>>>>>> theirs
+line 2
+"""
+            )
+
+        recorded, resolved = rerere_auto(self.repo, self.tempdir, [b"test.txt"])
+        self.assertEqual(1, len(recorded))
+        self.assertEqual(0, len(resolved))
+
+        path, conflict_id = recorded[0]
+        self.assertEqual(b"test.txt", path)
+        self.assertEqual(40, len(conflict_id))  # SHA-1 hash length
+
+    def test_rerere_auto_skips_non_conflicted_files(self) -> None:
+        """Test that rerere_auto skips files without conflict markers."""
+        from dulwich.rerere import rerere_auto
+
+        # Create a non-conflicted file
+        file_path = os.path.join(self.tempdir, "test.txt")
+        with open(file_path, "wb") as f:
+            f.write(b"line 1\nline 2\n")
+
+        recorded, resolved = rerere_auto(self.repo, self.tempdir, [b"test.txt"])
+        self.assertEqual([], recorded)
+        self.assertEqual([], resolved)
+
+    def test_rerere_auto_handles_missing_files(self) -> None:
+        """Test that rerere_auto handles deleted files gracefully."""
+        from dulwich.rerere import rerere_auto
+
+        # Don't create the file
+        recorded, resolved = rerere_auto(self.repo, self.tempdir, [b"missing.txt"])
+        self.assertEqual([], recorded)
+        self.assertEqual([], resolved)
+
+    def test_rerere_auto_applies_known_resolution(self) -> None:
+        """Test that rerere_auto applies known resolutions when autoupdate is enabled."""
+        from dulwich.rerere import RerereCache, rerere_auto
+
+        # Enable autoupdate
+        config = self.repo.get_config()
+        config.set((b"rerere",), b"autoupdate", b"true")
+        config.write_to_path()
+
+        # Create a conflicted file
+        conflict_file = os.path.join(self.tempdir, "test.txt")
+        conflict_content = b"""line 1
+<<<<<<< ours
+our change
+=======
+their change
+>>>>>>> theirs
+line 2
+"""
+        with open(conflict_file, "wb") as f:
+            f.write(conflict_content)
+
+        # Record the conflict first time
+        recorded, resolved = rerere_auto(self.repo, self.tempdir, [b"test.txt"])
+        self.assertEqual(1, len(recorded))
+        self.assertEqual(0, len(resolved))  # No resolution yet
+
+        conflict_id = recorded[0][1]
+
+        # Manually record a resolution
+        cache = RerereCache.from_repo(self.repo)
+        resolution = b"line 1\nresolved change\nline 2\n"
+        cache.record_resolution(conflict_id, resolution)
+
+        # Create the same conflict again
+        with open(conflict_file, "wb") as f:
+            f.write(conflict_content)
+
+        # rerere_auto should now apply the resolution
+        recorded2, resolved2 = rerere_auto(self.repo, self.tempdir, [b"test.txt"])
+        self.assertEqual(1, len(recorded2))
+        self.assertEqual(1, len(resolved2))
+        self.assertEqual(b"test.txt", resolved2[0])
+
+        # Verify the file was resolved
+        with open(conflict_file, "rb") as f:
+            actual = f.read()
+        self.assertEqual(resolution, actual)
+
+    def test_rerere_auto_no_apply_without_autoupdate(self) -> None:
+        """Test that rerere_auto doesn't apply resolutions when autoupdate is disabled."""
+        from dulwich.rerere import RerereCache, rerere_auto
+
+        # autoupdate is disabled by default
+
+        # Create a conflicted file
+        conflict_file = os.path.join(self.tempdir, "test.txt")
+        conflict_content = b"""line 1
+<<<<<<< ours
+our change
+=======
+their change
+>>>>>>> theirs
+line 2
+"""
+        with open(conflict_file, "wb") as f:
+            f.write(conflict_content)
+
+        # Record the conflict first time
+        recorded, _resolved = rerere_auto(self.repo, self.tempdir, [b"test.txt"])
+        conflict_id = recorded[0][1]
+
+        # Manually record a resolution
+        cache = RerereCache.from_repo(self.repo)
+        resolution = b"line 1\nresolved change\nline 2\n"
+        cache.record_resolution(conflict_id, resolution)
+
+        # Create the same conflict again
+        with open(conflict_file, "wb") as f:
+            f.write(conflict_content)
+
+        # rerere_auto should NOT apply the resolution (autoupdate disabled)
+        recorded2, resolved2 = rerere_auto(self.repo, self.tempdir, [b"test.txt"])
+        self.assertEqual(1, len(recorded2))
+        self.assertEqual(0, len(resolved2))  # Should not auto-apply
+
+        # Verify the file still has conflicts
+        with open(conflict_file, "rb") as f:
+            actual = f.read()
+        self.assertEqual(conflict_content, actual)
+
+
+class RerereEndToEndTests(unittest.TestCase):
+    """End-to-end tests for rerere with real merge operations."""
+
+    def setUp(self) -> None:
+        """Set up test fixtures."""
+
+        from dulwich.objects import Blob, Commit, Tree
+        from dulwich.repo import Repo
+
+        self.tempdir = tempfile.mkdtemp()
+        self.repo = Repo.init(self.tempdir)
+
+        # Enable rerere
+        config = self.repo.get_config()
+        config.set((b"rerere",), b"enabled", b"true")
+        config.write_to_path()
+
+        # Create initial commit on master
+        blob1 = Blob.from_string(b"line 1\noriginal line\nline 3\n")
+        self.repo.object_store.add_object(blob1)
+
+        tree1 = Tree()
+        tree1.add(b"file.txt", 0o100644, blob1.id)
+        self.repo.object_store.add_object(tree1)
+
+        commit1 = Commit()
+        commit1.tree = tree1.id
+        commit1.author = commit1.committer = b"Test User <test@example.com>"
+        commit1.author_time = commit1.commit_time = 1234567890
+        commit1.author_timezone = commit1.commit_timezone = 0
+        commit1.encoding = b"UTF-8"
+        commit1.message = b"Initial commit"
+        self.repo.object_store.add_object(commit1)
+
+        self.repo.refs[b"refs/heads/master"] = commit1.id
+        self.repo.refs[b"HEAD"] = commit1.id
+
+        # Write file to working tree
+        with open(os.path.join(self.tempdir, "file.txt"), "wb") as f:
+            f.write(b"line 1\noriginal line\nline 3\n")
+
+        self.initial_commit = commit1.id
+
+    def tearDown(self) -> None:
+        """Clean up test fixtures."""
+        import shutil
+
+        shutil.rmtree(self.tempdir, ignore_errors=True)
+
+    def test_rerere_full_workflow(self) -> None:
+        """Test complete rerere workflow with real merge conflicts."""
+        from dulwich.objects import Blob, Commit, Tree
+        from dulwich.porcelain import merge, rerere
+
+        # Create branch1: change "original line" to "branch1 change"
+        blob_branch1 = Blob.from_string(b"line 1\nbranch1 change\nline 3\n")
+        self.repo.object_store.add_object(blob_branch1)
+
+        tree_branch1 = Tree()
+        tree_branch1.add(b"file.txt", 0o100644, blob_branch1.id)
+        self.repo.object_store.add_object(tree_branch1)
+
+        commit_branch1 = Commit()
+        commit_branch1.tree = tree_branch1.id
+        commit_branch1.parents = [self.initial_commit]
+        commit_branch1.author = commit_branch1.committer = (
+            b"Test User <test@example.com>"
+        )
+        commit_branch1.author_time = commit_branch1.commit_time = 1234567891
+        commit_branch1.author_timezone = commit_branch1.commit_timezone = 0
+        commit_branch1.encoding = b"UTF-8"
+        commit_branch1.message = b"Branch1 changes"
+        self.repo.object_store.add_object(commit_branch1)
+        self.repo.refs[b"refs/heads/branch1"] = commit_branch1.id
+
+        # Create branch2: change "original line" to "branch2 change"
+        blob_branch2 = Blob.from_string(b"line 1\nbranch2 change\nline 3\n")
+        self.repo.object_store.add_object(blob_branch2)
+
+        tree_branch2 = Tree()
+        tree_branch2.add(b"file.txt", 0o100644, blob_branch2.id)
+        self.repo.object_store.add_object(tree_branch2)
+
+        commit_branch2 = Commit()
+        commit_branch2.tree = tree_branch2.id
+        commit_branch2.parents = [self.initial_commit]
+        commit_branch2.author = commit_branch2.committer = (
+            b"Test User <test@example.com>"
+        )
+        commit_branch2.author_time = commit_branch2.commit_time = 1234567892
+        commit_branch2.author_timezone = commit_branch2.commit_timezone = 0
+        commit_branch2.encoding = b"UTF-8"
+        commit_branch2.message = b"Branch2 changes"
+        self.repo.object_store.add_object(commit_branch2)
+        self.repo.refs[b"refs/heads/branch2"] = commit_branch2.id
+
+        # Checkout branch1
+        self.repo.refs[b"HEAD"] = commit_branch1.id
+        with open(os.path.join(self.tempdir, "file.txt"), "wb") as f:
+            f.write(b"line 1\nbranch1 change\nline 3\n")
+
+        # Merge branch2 into branch1 - should create conflict
+        merge_result, conflicts = merge(self.repo, b"branch2", no_commit=True)
+
+        # Should have conflicts
+        self.assertIsNone(merge_result)  # No commit created due to conflicts
+        self.assertEqual([b"file.txt"], conflicts)
+
+        # File should have conflict markers
+        with open(os.path.join(self.tempdir, "file.txt"), "rb") as f:
+            content = f.read()
+        self.assertIn(b"<<<<<<<", content)
+        self.assertIn(b"branch1 change", content)
+        self.assertIn(b"branch2 change", content)
+
+        # Record the conflict with rerere
+        recorded, resolved = rerere(self.repo)
+        self.assertEqual(1, len(recorded))
+        self.assertEqual(0, len(resolved))  # No resolution yet
+
+        conflict_id = recorded[0][1]
+
+        # User manually resolves the conflict
+        resolved_content = b"line 1\nmerged change\nline 3\n"
+        with open(os.path.join(self.tempdir, "file.txt"), "wb") as f:
+            f.write(resolved_content)
+
+        # Record the resolution
+        from dulwich.rerere import RerereCache
+
+        cache = RerereCache.from_repo(self.repo)
+        cache.record_resolution(conflict_id, resolved_content)
+
+        # Reset to initial state and try the merge again
+        self.repo.refs[b"HEAD"] = commit_branch1.id
+        with open(os.path.join(self.tempdir, "file.txt"), "wb") as f:
+            f.write(b"line 1\nbranch1 change\nline 3\n")
+
+        # Merge again - should create same conflict
+        _merge_result2, conflicts2 = merge(self.repo, b"branch2", no_commit=True)
+        self.assertEqual([b"file.txt"], conflicts2)
+
+        # Now rerere should recognize the conflict
+        recorded2, resolved2 = rerere(self.repo)
+        self.assertEqual(1, len(recorded2))
+
+        # With autoupdate disabled, it shouldn't auto-apply
+        self.assertEqual(0, len(resolved2))
+
+    def test_rerere_with_autoupdate(self) -> None:
+        """Test rerere with autoupdate enabled."""
+        from dulwich.objects import Blob, Commit, Tree
+        from dulwich.porcelain import merge, rerere
+        from dulwich.rerere import RerereCache
+
+        # Enable autoupdate
+        config = self.repo.get_config()
+        config.set((b"rerere",), b"autoupdate", b"true")
+        config.write_to_path()
+
+        # Create branch1
+        blob_branch1 = Blob.from_string(b"line 1\nbranch1 change\nline 3\n")
+        self.repo.object_store.add_object(blob_branch1)
+
+        tree_branch1 = Tree()
+        tree_branch1.add(b"file.txt", 0o100644, blob_branch1.id)
+        self.repo.object_store.add_object(tree_branch1)
+
+        commit_branch1 = Commit()
+        commit_branch1.tree = tree_branch1.id
+        commit_branch1.parents = [self.initial_commit]
+        commit_branch1.author = commit_branch1.committer = (
+            b"Test User <test@example.com>"
+        )
+        commit_branch1.author_time = commit_branch1.commit_time = 1234567891
+        commit_branch1.author_timezone = commit_branch1.commit_timezone = 0
+        commit_branch1.encoding = b"UTF-8"
+        commit_branch1.message = b"Branch1 changes"
+        self.repo.object_store.add_object(commit_branch1)
+        self.repo.refs[b"refs/heads/branch1"] = commit_branch1.id
+
+        # Create branch2
+        blob_branch2 = Blob.from_string(b"line 1\nbranch2 change\nline 3\n")
+        self.repo.object_store.add_object(blob_branch2)
+
+        tree_branch2 = Tree()
+        tree_branch2.add(b"file.txt", 0o100644, blob_branch2.id)
+        self.repo.object_store.add_object(tree_branch2)
+
+        commit_branch2 = Commit()
+        commit_branch2.tree = tree_branch2.id
+        commit_branch2.parents = [self.initial_commit]
+        commit_branch2.author = commit_branch2.committer = (
+            b"Test User <test@example.com>"
+        )
+        commit_branch2.author_time = commit_branch2.commit_time = 1234567892
+        commit_branch2.author_timezone = commit_branch2.commit_timezone = 0
+        commit_branch2.encoding = b"UTF-8"
+        commit_branch2.message = b"Branch2 changes"
+        self.repo.object_store.add_object(commit_branch2)
+        self.repo.refs[b"refs/heads/branch2"] = commit_branch2.id
+
+        # Checkout branch1 and merge branch2
+        self.repo.refs[b"HEAD"] = commit_branch1.id
+        with open(os.path.join(self.tempdir, "file.txt"), "wb") as f:
+            f.write(b"line 1\nbranch1 change\nline 3\n")
+
+        merge(self.repo, b"branch2", no_commit=True)
+
+        # Record conflict and resolution
+        recorded, _ = rerere(self.repo)
+        conflict_id = recorded[0][1]
+
+        resolved_content = b"line 1\nmerged change\nline 3\n"
+        with open(os.path.join(self.tempdir, "file.txt"), "wb") as f:
+            f.write(resolved_content)
+
+        cache = RerereCache.from_repo(self.repo)
+        cache.record_resolution(conflict_id, resolved_content)
+
+        # Reset and merge again
+        self.repo.refs[b"HEAD"] = commit_branch1.id
+        with open(os.path.join(self.tempdir, "file.txt"), "wb") as f:
+            f.write(b"line 1\nbranch1 change\nline 3\n")
+
+        merge(self.repo, b"branch2", no_commit=True)
+
+        # With autoupdate, rerere should auto-apply the resolution
+        recorded2, resolved2 = rerere(self.repo)
+        self.assertEqual(1, len(recorded2))
+        self.assertEqual(1, len(resolved2))
+        self.assertEqual(b"file.txt", resolved2[0])
+
+        # Verify the file was auto-resolved
+        with open(os.path.join(self.tempdir, "file.txt"), "rb") as f:
+            actual = f.read()
+        self.assertEqual(resolved_content, actual)