# 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
# for a copy of the GNU General Public License
# and 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 "
self.initial_commit.author = b"Test User "
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 "
feature_commit.author = b"Test User "
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 "
main_commit.author = b"Test User "
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 "
feature_commit.author = b"Test User "
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 "
main_commit.author = b"Test User "
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 "
feature_commit.author = b"Test User "
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 ",
author=b"Test User ",
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 ",
author=b"Test User ",
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 ",
author=b"Test User ",
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 ",
author=b"Test User ",
)
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 ",
committer="Test User ",
)
# 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 ",
committer="Test User ",
)
# 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)