Forráskód Böngészése

Add basic cherry-pick support

Jelmer Vernooij 1 hónapja
szülő
commit
3ebd7b1234
4 módosított fájl, 565 hozzáadás és 0 törlés
  1. 69 0
      dulwich/cli.py
  2. 154 0
      dulwich/porcelain.py
  3. 137 0
      tests/test_cli_cherry_pick.py
  4. 205 0
      tests/test_porcelain_cherry_pick.py

+ 69 - 0
dulwich/cli.py

@@ -1051,6 +1051,74 @@ class cmd_notes(SuperCommand):
     default_command = cmd_notes_list
 
 
+class cmd_cherry_pick(Command):
+    def run(self, args) -> Optional[int]:
+        parser = argparse.ArgumentParser(
+            description="Apply the changes introduced by some existing commits"
+        )
+        parser.add_argument("commit", nargs="?", help="Commit to cherry-pick")
+        parser.add_argument(
+            "-n",
+            "--no-commit",
+            action="store_true",
+            help="Apply changes without making a commit",
+        )
+        parser.add_argument(
+            "--continue",
+            dest="continue_",
+            action="store_true",
+            help="Continue after resolving conflicts",
+        )
+        parser.add_argument(
+            "--abort",
+            action="store_true",
+            help="Abort the current cherry-pick operation",
+        )
+        args = parser.parse_args(args)
+
+        # Check argument validity
+        if args.continue_ or args.abort:
+            if args.commit is not None:
+                parser.error("Cannot specify commit with --continue or --abort")
+                return 1
+        else:
+            if args.commit is None:
+                parser.error("Commit argument is required")
+                return 1
+
+        try:
+            commit_arg = args.commit
+
+            result = porcelain.cherry_pick(
+                ".",
+                commit_arg,
+                no_commit=args.no_commit,
+                continue_=args.continue_,
+                abort=args.abort,
+            )
+
+            if args.abort:
+                print("Cherry-pick aborted.")
+            elif args.continue_:
+                if result:
+                    print(f"Cherry-pick completed: {result.decode()}")
+                else:
+                    print("Cherry-pick completed.")
+            elif result is None:
+                if args.no_commit:
+                    print("Cherry-pick applied successfully (no commit created).")
+                else:
+                    # This shouldn't happen unless there were conflicts
+                    print("Cherry-pick resulted in conflicts.")
+            else:
+                print(f"Cherry-pick successful: {result.decode()}")
+
+            return None
+        except porcelain.Error as e:
+            print(f"Error: {e}", file=sys.stderr)
+            return 1
+
+
 class cmd_merge_tree(Command):
     def run(self, args) -> Optional[int]:
         parser = argparse.ArgumentParser(
@@ -1339,6 +1407,7 @@ commands = {
     "check-ignore": cmd_check_ignore,
     "check-mailmap": cmd_check_mailmap,
     "checkout": cmd_checkout,
+    "cherry-pick": cmd_cherry_pick,
     "clone": cmd_clone,
     "commit": cmd_commit,
     "commit-tree": cmd_commit_tree,

+ 154 - 0
dulwich/porcelain.py

@@ -3041,6 +3041,160 @@ def merge_tree(repo, base_tree, our_tree, their_tree):
         return merged_tree.id, conflicts
 
 
+def cherry_pick(
+    repo,
+    committish,
+    no_commit=False,
+    continue_=False,
+    abort=False,
+):
+    """Cherry-pick a commit onto the current branch.
+
+    Args:
+      repo: Repository to cherry-pick into
+      committish: Commit to cherry-pick
+      no_commit: If True, do not create a commit after applying changes
+      continue_: Continue an in-progress cherry-pick after resolving conflicts
+      abort: Abort an in-progress cherry-pick
+
+    Returns:
+      The SHA of the newly created commit, or None if no_commit=True or there were conflicts
+
+    Raises:
+      Error: If there is no HEAD reference, commit cannot be found, or operation fails
+    """
+    from .merge import three_way_merge
+
+    with open_repo_closing(repo) as r:
+        # Handle abort
+        if abort:
+            # Clean up any cherry-pick state
+            try:
+                os.remove(os.path.join(r.controldir(), "CHERRY_PICK_HEAD"))
+            except FileNotFoundError:
+                pass
+            try:
+                os.remove(os.path.join(r.controldir(), "MERGE_MSG"))
+            except FileNotFoundError:
+                pass
+            # Reset index to HEAD
+            r.reset_index(r[b"HEAD"].tree)
+            return None
+
+        # Handle continue
+        if continue_:
+            # Check if there's a cherry-pick in progress
+            cherry_pick_head_path = os.path.join(r.controldir(), "CHERRY_PICK_HEAD")
+            try:
+                with open(cherry_pick_head_path, "rb") as f:
+                    cherry_pick_commit_id = f.read().strip()
+                cherry_pick_commit = r[cherry_pick_commit_id]
+            except FileNotFoundError:
+                raise Error("No cherry-pick in progress")
+
+            # Check for unresolved conflicts
+            conflicts = list(r.open_index().conflicts())
+            if conflicts:
+                raise Error("Unresolved conflicts remain")
+
+            # Create the commit
+            tree_id = r.open_index().commit(r.object_store)
+
+            # Read saved message if any
+            merge_msg_path = os.path.join(r.controldir(), "MERGE_MSG")
+            try:
+                with open(merge_msg_path, "rb") as f:
+                    message = f.read()
+            except FileNotFoundError:
+                message = cherry_pick_commit.message
+
+            new_commit = r.do_commit(
+                message=message,
+                tree=tree_id,
+                author=cherry_pick_commit.author,
+                author_timestamp=cherry_pick_commit.author_time,
+                author_timezone=cherry_pick_commit.author_timezone,
+            )
+
+            # Clean up state files
+            try:
+                os.remove(cherry_pick_head_path)
+            except FileNotFoundError:
+                pass
+            try:
+                os.remove(merge_msg_path)
+            except FileNotFoundError:
+                pass
+
+            return new_commit
+
+        # Normal cherry-pick operation
+        # Get current HEAD
+        try:
+            head_commit = r[b"HEAD"]
+        except KeyError:
+            raise Error("No HEAD reference found")
+
+        # Parse the commit to cherry-pick
+        try:
+            cherry_pick_commit = parse_commit(r, committish)
+        except KeyError:
+            raise Error(f"Cannot find commit '{committish}'")
+
+        # Check if commit has parents
+        if not cherry_pick_commit.parents:
+            raise Error("Cannot cherry-pick root commit")
+
+        # Get parent of cherry-pick commit
+        parent_commit = r[cherry_pick_commit.parents[0]]
+
+        # Perform three-way merge
+        try:
+            merged_tree, conflicts = three_way_merge(
+                r.object_store, parent_commit, head_commit, cherry_pick_commit
+            )
+        except Exception as e:
+            raise Error(f"Cherry-pick failed: {e}")
+
+        # Add merged tree to object store
+        r.object_store.add_object(merged_tree)
+
+        # Update working tree and index
+        # Reset index to match merged tree
+        r.reset_index(merged_tree.id)
+
+        # Update working tree from the new index
+        update_working_tree(r, head_commit.tree, merged_tree.id)
+
+        if conflicts:
+            # Save state for later continuation
+            with open(os.path.join(r.controldir(), "CHERRY_PICK_HEAD"), "wb") as f:
+                f.write(cherry_pick_commit.id + b"\n")
+
+            # Save commit message
+            with open(os.path.join(r.controldir(), "MERGE_MSG"), "wb") as f:
+                f.write(cherry_pick_commit.message)
+
+            raise Error(
+                f"Conflicts in: {', '.join(c.decode('utf-8', 'replace') for c in conflicts)}\n"
+                f"Fix conflicts and run 'dulwich cherry-pick --continue'"
+            )
+
+        if no_commit:
+            return None
+
+        # Create the commit
+        new_commit = r.do_commit(
+            message=cherry_pick_commit.message,
+            tree=merged_tree.id,
+            author=cherry_pick_commit.author,
+            author_timestamp=cherry_pick_commit.author_time,
+            author_timezone=cherry_pick_commit.author_timezone,
+        )
+
+        return new_commit
+
+
 def gc(
     repo,
     auto: bool = False,

+ 137 - 0
tests/test_cli_cherry_pick.py

@@ -0,0 +1,137 @@
+# test_cli_cherry_pick.py -- Tests for CLI cherry-pick command
+# Copyright (C) 2024 Jelmer Vernooij <jelmer@jelmer.uk>
+#
+# SPDX-License-Identifier: Apache-2.0 OR GPL-2.0-or-later
+# Dulwich is dual-licensed under the Apache License, Version 2.0 and the GNU
+# General Public License as public by the Free Software Foundation; version 2.0
+# or (at your option) any later version. You can redistribute it and/or
+# modify it under the terms of either of these two licenses.
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# You should have received a copy of the licenses; if not, see
+# <http://www.gnu.org/licenses/> for a copy of the GNU General Public License
+# and <http://www.apache.org/licenses/LICENSE-2.0> for a copy of the Apache
+# License, Version 2.0.
+#
+
+"""Tests for CLI cherry-pick command."""
+
+import os
+import tempfile
+
+from dulwich import porcelain
+from dulwich.cli import cmd_cherry_pick
+
+from . import TestCase
+
+
+class CherryPickCommandTests(TestCase):
+    """Tests for the cherry-pick CLI command."""
+
+    def test_cherry_pick_simple(self):
+        """Test simple cherry-pick via CLI."""
+        with tempfile.TemporaryDirectory() as tmpdir:
+            # Save current directory
+            orig_cwd = os.getcwd()
+            try:
+                os.chdir(tmpdir)
+
+                # Initialize repo
+                porcelain.init(".")
+
+                # Create initial commit
+                with open("file1.txt", "w") as f:
+                    f.write("Initial content\n")
+                porcelain.add(".", paths=["file1.txt"])
+                porcelain.commit(".", message=b"Initial commit")
+
+                # Create a branch and switch to it
+                porcelain.branch_create(".", "feature")
+                porcelain.checkout_branch(".", "feature")
+
+                # Add a file on feature branch
+                with open("file2.txt", "w") as f:
+                    f.write("Feature content\n")
+                porcelain.add(".", paths=["file2.txt"])
+                feature_commit = porcelain.commit(".", message=b"Add feature file")
+
+                # Go back to master
+                porcelain.checkout_branch(".", "master")
+
+                # Cherry-pick via CLI
+                cmd = cmd_cherry_pick()
+                result = cmd.run([feature_commit.decode()])
+
+                self.assertIsNone(result)  # Success
+                self.assertTrue(os.path.exists("file2.txt"))
+
+            finally:
+                os.chdir(orig_cwd)
+
+    def test_cherry_pick_no_commit(self):
+        """Test cherry-pick with --no-commit via CLI."""
+        with tempfile.TemporaryDirectory() as tmpdir:
+            orig_cwd = os.getcwd()
+            try:
+                os.chdir(tmpdir)
+
+                # Initialize repo
+                porcelain.init(".")
+
+                # Create initial commit
+                with open("file1.txt", "w") as f:
+                    f.write("Initial content\n")
+                porcelain.add(".", paths=["file1.txt"])
+                porcelain.commit(".", message=b"Initial commit")
+
+                # Create a branch and switch to it
+                porcelain.branch_create(".", "feature")
+                porcelain.checkout_branch(".", "feature")
+
+                # Add a file on feature branch
+                with open("file2.txt", "w") as f:
+                    f.write("Feature content\n")
+                porcelain.add(".", paths=["file2.txt"])
+                feature_commit = porcelain.commit(".", message=b"Add feature file")
+
+                # Go back to master
+                porcelain.checkout_branch(".", "master")
+
+                # Cherry-pick with --no-commit
+                cmd = cmd_cherry_pick()
+                result = cmd.run(["--no-commit", feature_commit.decode()])
+
+                self.assertIsNone(result)  # Success
+                self.assertTrue(os.path.exists("file2.txt"))
+
+                # Check that file is staged but not committed
+                status = porcelain.status(".")
+                self.assertTrue(
+                    any(b"file2.txt" in changes for changes in status.staged.values())
+                )
+
+            finally:
+                os.chdir(orig_cwd)
+
+    def test_cherry_pick_missing_argument(self):
+        """Test cherry-pick without commit argument."""
+        with tempfile.TemporaryDirectory() as tmpdir:
+            orig_cwd = os.getcwd()
+            try:
+                os.chdir(tmpdir)
+                porcelain.init(".")
+
+                # Try to cherry-pick without argument
+                cmd = cmd_cherry_pick()
+                with self.assertRaises(SystemExit) as cm:
+                    cmd.run([])
+
+                self.assertEqual(cm.exception.code, 2)  # argparse error code
+
+            finally:
+                os.chdir(orig_cwd)

+ 205 - 0
tests/test_porcelain_cherry_pick.py

@@ -0,0 +1,205 @@
+# test_porcelain_cherry_pick.py -- Tests for porcelain cherry-pick functionality
+# Copyright (C) 2024 Jelmer Vernooij <jelmer@jelmer.uk>
+#
+# SPDX-License-Identifier: Apache-2.0 OR GPL-2.0-or-later
+# Dulwich is dual-licensed under the Apache License, Version 2.0 and the GNU
+# General Public License as public by the Free Software Foundation; version 2.0
+# or (at your option) any later version. You can redistribute it and/or
+# modify it under the terms of either of these two licenses.
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# You should have received a copy of the licenses; if not, see
+# <http://www.gnu.org/licenses/> for a copy of the GNU General Public License
+# and <http://www.apache.org/licenses/LICENSE-2.0> for a copy of the Apache
+# License, Version 2.0.
+#
+
+"""Tests for porcelain cherry-pick functionality."""
+
+import os
+import tempfile
+
+from dulwich import porcelain
+
+from . import TestCase
+
+
+class PorcelainCherryPickTests(TestCase):
+    """Tests for the porcelain cherry-pick functionality."""
+
+    def test_cherry_pick_simple(self):
+        """Test simple cherry-pick."""
+        with tempfile.TemporaryDirectory() as tmpdir:
+            # Initialize repo
+            porcelain.init(tmpdir)
+
+            # Create initial commit
+            with open(os.path.join(tmpdir, "file1.txt"), "w") as f:
+                f.write("Initial content\n")
+            porcelain.add(tmpdir, paths=["file1.txt"])
+            porcelain.commit(tmpdir, message=b"Initial commit")
+
+            # Create a branch and switch to it
+            porcelain.branch_create(tmpdir, "feature")
+            porcelain.checkout_branch(tmpdir, "feature")
+
+            # Add a file on feature branch
+            with open(os.path.join(tmpdir, "file2.txt"), "w") as f:
+                f.write("Feature content\n")
+            porcelain.add(tmpdir, paths=["file2.txt"])
+            feature_commit = porcelain.commit(tmpdir, message=b"Add feature file")
+
+            # Go back to master
+            porcelain.checkout_branch(tmpdir, "master")
+
+            # Cherry-pick the feature commit
+            new_commit = porcelain.cherry_pick(tmpdir, feature_commit)
+
+            self.assertIsNotNone(new_commit)
+            self.assertTrue(os.path.exists(os.path.join(tmpdir, "file2.txt")))
+
+            # Check the content
+            with open(os.path.join(tmpdir, "file2.txt")) as f:
+                self.assertEqual(f.read(), "Feature content\n")
+
+    def test_cherry_pick_no_commit(self):
+        """Test cherry-pick with --no-commit."""
+        with tempfile.TemporaryDirectory() as tmpdir:
+            # Initialize repo
+            porcelain.init(tmpdir)
+
+            # Create initial commit
+            with open(os.path.join(tmpdir, "file1.txt"), "w") as f:
+                f.write("Initial content\n")
+            porcelain.add(tmpdir, paths=["file1.txt"])
+            porcelain.commit(tmpdir, message=b"Initial commit")
+
+            # Create a branch and switch to it
+            porcelain.branch_create(tmpdir, "feature")
+            porcelain.checkout_branch(tmpdir, "feature")
+
+            # Add a file on feature branch
+            with open(os.path.join(tmpdir, "file2.txt"), "w") as f:
+                f.write("Feature content\n")
+            porcelain.add(tmpdir, paths=["file2.txt"])
+            feature_commit = porcelain.commit(tmpdir, message=b"Add feature file")
+
+            # Go back to master
+            porcelain.checkout_branch(tmpdir, "master")
+
+            # Cherry-pick with no-commit
+            result = porcelain.cherry_pick(tmpdir, feature_commit, no_commit=True)
+
+            self.assertIsNone(result)
+            self.assertTrue(os.path.exists(os.path.join(tmpdir, "file2.txt")))
+
+            # Check that the change is staged but not committed
+            status = porcelain.status(tmpdir)
+            # The file should be in staged changes
+            self.assertTrue(
+                any(b"file2.txt" in changes for changes in status.staged.values())
+            )
+
+    def test_cherry_pick_conflict(self):
+        """Test cherry-pick with conflicts."""
+        with tempfile.TemporaryDirectory() as tmpdir:
+            # Initialize repo
+            porcelain.init(tmpdir)
+
+            # Create initial commit with a file
+            with open(os.path.join(tmpdir, "file1.txt"), "w") as f:
+                f.write("Initial content\n")
+            porcelain.add(tmpdir, paths=["file1.txt"])
+            porcelain.commit(tmpdir, message=b"Initial commit")
+
+            # Create a branch and modify the file
+            porcelain.branch_create(tmpdir, "feature")
+            porcelain.checkout_branch(tmpdir, "feature")
+
+            with open(os.path.join(tmpdir, "file1.txt"), "w") as f:
+                f.write("Feature modification\n")
+            porcelain.add(tmpdir, paths=["file1.txt"])
+            feature_commit = porcelain.commit(tmpdir, message=b"Modify file on feature")
+
+            # Go back to master and make a different modification
+            porcelain.checkout_branch(tmpdir, "master")
+            with open(os.path.join(tmpdir, "file1.txt"), "w") as f:
+                f.write("Master modification\n")
+            porcelain.add(tmpdir, paths=["file1.txt"])
+            porcelain.commit(tmpdir, message=b"Modify file on master")
+
+            # Try to cherry-pick - should raise error due to conflicts
+            with self.assertRaises(porcelain.Error) as cm:
+                porcelain.cherry_pick(tmpdir, feature_commit)
+
+            self.assertIn("Conflicts in:", str(cm.exception))
+            self.assertIn("file1.txt", str(cm.exception))
+
+    def test_cherry_pick_root_commit(self):
+        """Test cherry-pick of root commit (should fail)."""
+        with tempfile.TemporaryDirectory() as tmpdir:
+            # Initialize repo
+            porcelain.init(tmpdir)
+
+            # Create initial commit
+            with open(os.path.join(tmpdir, "file1.txt"), "w") as f:
+                f.write("Initial content\n")
+            porcelain.add(tmpdir, paths=["file1.txt"])
+            root_commit = porcelain.commit(tmpdir, message=b"Root commit")
+
+            # Create another branch with a different initial commit
+            porcelain.branch_create(tmpdir, "other")
+            porcelain.checkout_branch(tmpdir, "other")
+
+            # Try to cherry-pick root commit - should fail
+            with self.assertRaises(porcelain.Error) as cm:
+                porcelain.cherry_pick(tmpdir, root_commit)
+
+            self.assertIn("Cannot cherry-pick root commit", str(cm.exception))
+
+    def test_cherry_pick_abort(self):
+        """Test aborting a cherry-pick."""
+        with tempfile.TemporaryDirectory() as tmpdir:
+            # Initialize repo
+            porcelain.init(tmpdir)
+
+            # Create initial commit
+            with open(os.path.join(tmpdir, "file1.txt"), "w") as f:
+                f.write("Initial content\n")
+            porcelain.add(tmpdir, paths=["file1.txt"])
+            porcelain.commit(tmpdir, message=b"Initial commit")
+
+            # Create branch and make conflicting changes
+            porcelain.branch_create(tmpdir, "feature")
+            porcelain.checkout_branch(tmpdir, "feature")
+
+            with open(os.path.join(tmpdir, "file1.txt"), "w") as f:
+                f.write("Feature content\n")
+            porcelain.add(tmpdir, paths=["file1.txt"])
+            feature_commit = porcelain.commit(tmpdir, message=b"Feature change")
+
+            # Go back to master and make different change
+            porcelain.checkout_branch(tmpdir, "master")
+            with open(os.path.join(tmpdir, "file1.txt"), "w") as f:
+                f.write("Master content\n")
+            porcelain.add(tmpdir, paths=["file1.txt"])
+            porcelain.commit(tmpdir, message=b"Master change")
+
+            # Try to cherry-pick - will conflict
+            try:
+                porcelain.cherry_pick(tmpdir, feature_commit)
+            except porcelain.Error:
+                pass  # Expected
+
+            # Abort the cherry-pick
+            result = porcelain.cherry_pick(tmpdir, None, abort=True)
+            self.assertIsNone(result)
+
+            # Check that we're back to the master state
+            with open(os.path.join(tmpdir, "file1.txt")) as f:
+                self.assertEqual(f.read(), "Master content\n")