소스 검색

Implement git revert command (#1599)

Add revert functionality to dulwich.porcelain and CLI that creates new
commits to undo changes from specified commits while preserving history.
Jelmer Vernooij 1 개월 전
부모
커밋
3ee115dfc4
4개의 변경된 파일334개의 추가작업 그리고 0개의 파일을 삭제
  1. 3 0
      NEWS
  2. 22 0
      dulwich/cli.py
  3. 141 0
      dulwich/porcelain.py
  4. 168 0
      tests/test_porcelain.py

+ 3 - 0
NEWS

@@ -4,6 +4,9 @@
 
  * Add basic ``cherry-pick`` subcommand.  (#1599, Jelmer Vernooij)
 
+ * Add ``revert`` command to ``dulwich.porcelain`` and CLI.
+   (#1599, Jelmer Vernooij)
+
 0.23.0	2025-06-21
 
  * Add basic ``rebase`` subcommand. (Jelmer Vernooij)

+ 22 - 0
dulwich/cli.py

@@ -524,6 +524,27 @@ class cmd_reset(Command):
             raise NotImplementedError("Mixed reset not yet implemented")
 
 
+class cmd_revert(Command):
+    def run(self, args) -> None:
+        parser = argparse.ArgumentParser()
+        parser.add_argument(
+            "--no-commit",
+            "-n",
+            action="store_true",
+            help="Apply changes but don't create a commit",
+        )
+        parser.add_argument("-m", "--message", help="Custom commit message")
+        parser.add_argument("commits", nargs="+", help="Commits to revert")
+        args = parser.parse_args(args)
+
+        result = porcelain.revert(
+            ".", commits=args.commits, no_commit=args.no_commit, message=args.message
+        )
+
+        if result and not args.no_commit:
+            print(f"[{result.decode('ascii')[:7]}] Revert completed")
+
+
 class cmd_daemon(Command):
     def run(self, args) -> None:
         from dulwich import log_utils
@@ -1441,6 +1462,7 @@ commands = {
     "remote": cmd_remote,
     "repack": cmd_repack,
     "reset": cmd_reset,
+    "revert": cmd_revert,
     "rev-list": cmd_rev_list,
     "rm": cmd_rm,
     "show": cmd_show,

+ 141 - 0
dulwich/porcelain.py

@@ -49,6 +49,7 @@ Currently implemented:
  * remote{_add}
  * receive_pack
  * reset
+ * revert
  * sparse_checkout
  * submodule_add
  * submodule_init
@@ -3195,6 +3196,146 @@ def cherry_pick(
         return new_commit
 
 
+def revert(
+    repo,
+    commits,
+    no_commit=False,
+    message=None,
+    author=None,
+    committer=None,
+):
+    """Revert one or more commits.
+
+    This creates a new commit that undoes the changes introduced by the
+    specified commits. Unlike reset, revert creates a new commit that
+    preserves history.
+
+    Args:
+      repo: Path to repository or repository object
+      commits: List of commit-ish (SHA, ref, etc.) to revert, or a single commit-ish
+      no_commit: If True, apply changes to index/working tree but don't commit
+      message: Optional commit message (default: "Revert <original subject>")
+      author: Optional author for revert commit
+      committer: Optional committer for revert commit
+
+    Returns:
+      SHA1 of the new revert commit, or None if no_commit=True
+
+    Raises:
+      Error: If revert fails due to conflicts or other issues
+    """
+    from .merge import three_way_merge
+
+    # Normalize commits to a list
+    if isinstance(commits, (str, bytes)):
+        commits = [commits]
+
+    with open_repo_closing(repo) as r:
+        # Convert string refs to bytes
+        commits_to_revert = []
+        for commit_ref in commits:
+            if isinstance(commit_ref, str):
+                commit_ref = commit_ref.encode("utf-8")
+            commit = parse_commit(r, commit_ref)
+            commits_to_revert.append(commit)
+
+        # Get current HEAD
+        try:
+            head_commit_id = r.refs[b"HEAD"]
+        except KeyError:
+            raise Error("No HEAD reference found")
+
+        head_commit = r[head_commit_id]
+        current_tree = head_commit.tree
+
+        # Process commits in order
+        for commit_to_revert in commits_to_revert:
+            # For revert, we want to apply the inverse of the commit
+            # This means using the commit's tree as "base" and its parent as "theirs"
+
+            if not commit_to_revert.parents:
+                raise Error(
+                    f"Cannot revert commit {commit_to_revert.id} - it has no parents"
+                )
+
+            # For simplicity, we only handle commits with one parent (no merge commits)
+            if len(commit_to_revert.parents) > 1:
+                raise Error(
+                    f"Cannot revert merge commit {commit_to_revert.id} - not yet implemented"
+                )
+
+            parent_commit = r[commit_to_revert.parents[0]]
+
+            # Perform three-way merge:
+            # - base: the commit we're reverting (what we want to remove)
+            # - ours: current HEAD (what we have now)
+            # - theirs: parent of commit being reverted (what we want to go back to)
+            merged_tree, conflicts = three_way_merge(
+                r.object_store,
+                commit_to_revert,  # base
+                r[head_commit_id],  # ours
+                parent_commit,  # theirs
+            )
+
+            if conflicts:
+                # Update working tree with conflicts
+                update_working_tree(r, current_tree, merged_tree.id)
+                conflicted_paths = [c.decode("utf-8", "replace") for c in conflicts]
+                raise Error(f"Conflicts while reverting: {', '.join(conflicted_paths)}")
+
+            # Add merged tree to object store
+            r.object_store.add_object(merged_tree)
+
+            # Update working tree
+            update_working_tree(r, current_tree, merged_tree.id)
+            current_tree = merged_tree.id
+
+            if not no_commit:
+                # Create revert commit
+                revert_commit = Commit()
+                revert_commit.tree = merged_tree.id
+                revert_commit.parents = [head_commit_id]
+
+                # Set author/committer
+                if author is None:
+                    author = get_user_identity(r.get_config_stack())
+                if committer is None:
+                    committer = author
+
+                revert_commit.author = author
+                revert_commit.committer = committer
+
+                # Set timestamps
+                timestamp = int(time.time())
+                timezone = 0  # UTC
+                revert_commit.author_time = timestamp
+                revert_commit.author_timezone = timezone
+                revert_commit.commit_time = timestamp
+                revert_commit.commit_timezone = timezone
+
+                # Set message
+                if message is None:
+                    # Extract original commit subject
+                    original_message = commit_to_revert.message
+                    if isinstance(original_message, bytes):
+                        original_message = original_message.decode("utf-8", "replace")
+                    subject = original_message.split("\n")[0]
+                    message = f'Revert "{subject}"\n\nThis reverts commit {commit_to_revert.id.decode("ascii")}.'.encode()
+                elif isinstance(message, str):
+                    message = message.encode("utf-8")
+
+                revert_commit.message = message
+
+                # Add commit to object store
+                r.object_store.add_object(revert_commit)
+
+                # Update HEAD
+                r.refs[b"HEAD"] = revert_commit.id
+                head_commit_id = revert_commit.id
+
+        return head_commit_id if not no_commit else None
+
+
 def gc(
     repo,
     auto: bool = False,

+ 168 - 0
tests/test_porcelain.py

@@ -2202,6 +2202,174 @@ def _commit_file_with_content(repo, filename, content):
     return sha, file_path
 
 
+class RevertTests(PorcelainTestCase):
+    def test_revert_simple(self) -> None:
+        # Create initial commit
+        fullpath = os.path.join(self.repo.path, "foo")
+        with open(fullpath, "w") as f:
+            f.write("initial content\n")
+        porcelain.add(self.repo.path, paths=[fullpath])
+        porcelain.commit(
+            self.repo.path,
+            message=b"Initial commit",
+            committer=b"Jane <jane@example.com>",
+            author=b"John <john@example.com>",
+        )
+
+        # Make a change
+        with open(fullpath, "w") as f:
+            f.write("modified content\n")
+        porcelain.add(self.repo.path, paths=[fullpath])
+        change_sha = porcelain.commit(
+            self.repo.path,
+            message=b"Change content",
+            committer=b"Jane <jane@example.com>",
+            author=b"John <john@example.com>",
+        )
+
+        # Revert the change
+        revert_sha = porcelain.revert(self.repo.path, commits=[change_sha])
+
+        # Check the file content is back to initial
+        with open(fullpath) as f:
+            self.assertEqual("initial content\n", f.read())
+
+        # Check the revert commit message
+        revert_commit = self.repo[revert_sha]
+        self.assertIn(b'Revert "Change content"', revert_commit.message)
+        self.assertIn(change_sha[:7], revert_commit.message)
+
+    def test_revert_multiple(self) -> None:
+        # Create initial commit
+        fullpath = os.path.join(self.repo.path, "foo")
+        with open(fullpath, "w") as f:
+            f.write("line1\n")
+        porcelain.add(self.repo.path, paths=[fullpath])
+        porcelain.commit(
+            self.repo.path,
+            message=b"Initial commit",
+            committer=b"Jane <jane@example.com>",
+            author=b"John <john@example.com>",
+        )
+
+        # Add line2
+        with open(fullpath, "a") as f:
+            f.write("line2\n")
+        porcelain.add(self.repo.path, paths=[fullpath])
+        commit1 = porcelain.commit(
+            self.repo.path,
+            message=b"Add line2",
+            committer=b"Jane <jane@example.com>",
+            author=b"John <john@example.com>",
+        )
+
+        # Add line3
+        with open(fullpath, "a") as f:
+            f.write("line3\n")
+        porcelain.add(self.repo.path, paths=[fullpath])
+        commit2 = porcelain.commit(
+            self.repo.path,
+            message=b"Add line3",
+            committer=b"Jane <jane@example.com>",
+            author=b"John <john@example.com>",
+        )
+
+        # Revert both commits (in reverse order)
+        porcelain.revert(self.repo.path, commits=[commit2, commit1])
+
+        # Check file is back to initial state
+        with open(fullpath) as f:
+            self.assertEqual("line1\n", f.read())
+
+    def test_revert_no_commit(self) -> None:
+        # Create initial commit
+        fullpath = os.path.join(self.repo.path, "foo")
+        with open(fullpath, "w") as f:
+            f.write("initial\n")
+        porcelain.add(self.repo.path, paths=[fullpath])
+        porcelain.commit(
+            self.repo.path,
+            message=b"Initial",
+            committer=b"Jane <jane@example.com>",
+            author=b"John <john@example.com>",
+        )
+
+        # Make a change
+        with open(fullpath, "w") as f:
+            f.write("changed\n")
+        porcelain.add(self.repo.path, paths=[fullpath])
+        change_sha = porcelain.commit(
+            self.repo.path,
+            message=b"Change",
+            committer=b"Jane <jane@example.com>",
+            author=b"John <john@example.com>",
+        )
+
+        # Revert with no_commit
+        result = porcelain.revert(self.repo.path, commits=[change_sha], no_commit=True)
+
+        # Should return None
+        self.assertIsNone(result)
+
+        # File should be reverted
+        with open(fullpath) as f:
+            self.assertEqual("initial\n", f.read())
+
+        # HEAD should still point to the change commit
+        self.assertEqual(self.repo.refs[b"HEAD"], change_sha)
+
+    def test_revert_custom_message(self) -> None:
+        # Create commits
+        fullpath = os.path.join(self.repo.path, "foo")
+        with open(fullpath, "w") as f:
+            f.write("initial\n")
+        porcelain.add(self.repo.path, paths=[fullpath])
+        porcelain.commit(
+            self.repo.path,
+            message=b"Initial",
+            committer=b"Jane <jane@example.com>",
+            author=b"John <john@example.com>",
+        )
+
+        with open(fullpath, "w") as f:
+            f.write("changed\n")
+        porcelain.add(self.repo.path, paths=[fullpath])
+        change_sha = porcelain.commit(
+            self.repo.path,
+            message=b"Change",
+            committer=b"Jane <jane@example.com>",
+            author=b"John <john@example.com>",
+        )
+
+        # Revert with custom message
+        custom_msg = "Custom revert message"
+        revert_sha = porcelain.revert(
+            self.repo.path, commits=[change_sha], message=custom_msg
+        )
+
+        # Check the message
+        revert_commit = self.repo[revert_sha]
+        self.assertEqual(custom_msg.encode("utf-8"), revert_commit.message)
+
+    def test_revert_no_parent(self) -> None:
+        # Try to revert the initial commit (no parent)
+        fullpath = os.path.join(self.repo.path, "foo")
+        with open(fullpath, "w") as f:
+            f.write("content\n")
+        porcelain.add(self.repo.path, paths=[fullpath])
+        initial_sha = porcelain.commit(
+            self.repo.path,
+            message=b"Initial",
+            committer=b"Jane <jane@example.com>",
+            author=b"John <john@example.com>",
+        )
+
+        # Should raise an error
+        with self.assertRaises(porcelain.Error) as cm:
+            porcelain.revert(self.repo.path, commits=[initial_sha])
+        self.assertIn("no parents", str(cm.exception))
+
+
 class CheckoutTests(PorcelainTestCase):
     def setUp(self) -> None:
         super().setUp()