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