test_rebase.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456
  1. # test_rebase.py -- rebase tests
  2. # Copyright (C) 2025 Dulwich contributors
  3. #
  4. # SPDX-License-Identifier: Apache-2.0 OR GPL-2.0-or-later
  5. # Dulwich is dual-licensed under the Apache License, Version 2.0 and the GNU
  6. # General Public License as public by the Free Software Foundation; version 2.0
  7. # or (at your option) any later version. You can redistribute it and/or
  8. # modify it under the terms of either of these two licenses.
  9. #
  10. # Unless required by applicable law or agreed to in writing, software
  11. # distributed under the License is distributed on an "AS IS" BASIS,
  12. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13. # See the License for the specific language governing permissions and
  14. # limitations under the License.
  15. #
  16. # You should have received a copy of the licenses; if not, see
  17. # <http://www.gnu.org/licenses/> for a copy of the GNU General Public License
  18. # and <http://www.apache.org/licenses/LICENSE-2.0> for a copy of the Apache
  19. # License, Version 2.0.
  20. #
  21. """Tests for dulwich.rebase."""
  22. import os
  23. import tempfile
  24. from dulwich.objects import Blob, Commit, Tree
  25. from dulwich.rebase import RebaseConflict, Rebaser, rebase
  26. from dulwich.repo import MemoryRepo, Repo
  27. from dulwich.tests.utils import make_commit
  28. from . import TestCase
  29. class RebaserTestCase(TestCase):
  30. """Tests for the Rebaser class."""
  31. def setUp(self):
  32. """Set up test repository."""
  33. super().setUp()
  34. self.repo = MemoryRepo()
  35. def _setup_initial_commit(self):
  36. """Set up initial commit for tests."""
  37. # Create initial commit
  38. blob = Blob.from_string(b"Initial content\n")
  39. self.repo.object_store.add_object(blob)
  40. tree = Tree()
  41. tree.add(b"file.txt", 0o100644, blob.id)
  42. self.repo.object_store.add_object(tree)
  43. self.initial_commit = Commit()
  44. self.initial_commit.tree = tree.id
  45. self.initial_commit.parents = []
  46. self.initial_commit.message = b"Initial commit"
  47. self.initial_commit.committer = b"Test User <test@example.com>"
  48. self.initial_commit.author = b"Test User <test@example.com>"
  49. self.initial_commit.commit_time = 1000000
  50. self.initial_commit.author_time = 1000000
  51. self.initial_commit.commit_timezone = 0
  52. self.initial_commit.author_timezone = 0
  53. self.repo.object_store.add_object(self.initial_commit)
  54. # Set up branches
  55. self.repo.refs[b"refs/heads/master"] = self.initial_commit.id
  56. self.repo.refs.set_symbolic_ref(b"HEAD", b"refs/heads/master")
  57. def test_simple_rebase(self):
  58. """Test simple rebase with no conflicts."""
  59. self._setup_initial_commit()
  60. # Create feature branch with one commit
  61. feature_blob = Blob.from_string(b"Feature content\n")
  62. self.repo.object_store.add_object(feature_blob)
  63. feature_tree = Tree()
  64. feature_tree.add(b"feature.txt", 0o100644, feature_blob.id)
  65. feature_tree.add(
  66. b"file.txt", 0o100644, self.repo[self.initial_commit.tree][b"file.txt"][1]
  67. )
  68. self.repo.object_store.add_object(feature_tree)
  69. feature_commit = Commit()
  70. feature_commit.tree = feature_tree.id
  71. feature_commit.parents = [self.initial_commit.id]
  72. feature_commit.message = b"Add feature"
  73. feature_commit.committer = b"Test User <test@example.com>"
  74. feature_commit.author = b"Test User <test@example.com>"
  75. feature_commit.commit_time = 1000100
  76. feature_commit.author_time = 1000100
  77. feature_commit.commit_timezone = 0
  78. feature_commit.author_timezone = 0
  79. self.repo.object_store.add_object(feature_commit)
  80. self.repo.refs[b"refs/heads/feature"] = feature_commit.id
  81. # Create main branch advancement
  82. main_blob = Blob.from_string(b"Main advancement\n")
  83. self.repo.object_store.add_object(main_blob)
  84. main_tree = Tree()
  85. main_tree.add(b"main.txt", 0o100644, main_blob.id)
  86. main_tree.add(
  87. b"file.txt", 0o100644, self.repo[self.initial_commit.tree][b"file.txt"][1]
  88. )
  89. self.repo.object_store.add_object(main_tree)
  90. main_commit = Commit()
  91. main_commit.tree = main_tree.id
  92. main_commit.parents = [self.initial_commit.id]
  93. main_commit.message = b"Main advancement"
  94. main_commit.committer = b"Test User <test@example.com>"
  95. main_commit.author = b"Test User <test@example.com>"
  96. main_commit.commit_time = 1000200
  97. main_commit.author_time = 1000200
  98. main_commit.commit_timezone = 0
  99. main_commit.author_timezone = 0
  100. self.repo.object_store.add_object(main_commit)
  101. self.repo.refs[b"refs/heads/master"] = main_commit.id
  102. # Switch to feature branch
  103. self.repo.refs.set_symbolic_ref(b"HEAD", b"refs/heads/feature")
  104. # Check refs before rebase
  105. main_ref_before = self.repo.refs[b"refs/heads/master"]
  106. feature_ref_before = self.repo.refs[b"refs/heads/feature"]
  107. # Double check that refs are correctly set up
  108. self.assertEqual(main_ref_before, main_commit.id)
  109. self.assertEqual(feature_ref_before, feature_commit.id)
  110. # Perform rebase
  111. rebaser = Rebaser(self.repo)
  112. commits = rebaser.start(b"refs/heads/master", branch=b"refs/heads/feature")
  113. self.assertEqual(len(commits), 1)
  114. self.assertEqual(commits[0].id, feature_commit.id)
  115. # Continue rebase
  116. result = rebaser.continue_()
  117. self.assertIsNone(result) # Rebase complete
  118. # Check that feature branch was updated
  119. new_feature_head = self.repo.refs[b"refs/heads/feature"]
  120. new_commit = self.repo[new_feature_head]
  121. # Should have main commit as parent
  122. self.assertEqual(new_commit.parents, [main_commit.id])
  123. # Should have same tree as original (both files present)
  124. new_tree = self.repo[new_commit.tree]
  125. self.assertIn(b"feature.txt", new_tree)
  126. self.assertIn(b"main.txt", new_tree)
  127. self.assertIn(b"file.txt", new_tree)
  128. def test_rebase_with_conflicts(self):
  129. """Test rebase with merge conflicts."""
  130. self._setup_initial_commit()
  131. # Create feature branch with conflicting change
  132. feature_blob = Blob.from_string(b"Feature change to file\n")
  133. self.repo.object_store.add_object(feature_blob)
  134. feature_tree = Tree()
  135. feature_tree.add(b"file.txt", 0o100644, feature_blob.id)
  136. self.repo.object_store.add_object(feature_tree)
  137. feature_commit = Commit()
  138. feature_commit.tree = feature_tree.id
  139. feature_commit.parents = [self.initial_commit.id]
  140. feature_commit.message = b"Feature change"
  141. feature_commit.committer = b"Test User <test@example.com>"
  142. feature_commit.author = b"Test User <test@example.com>"
  143. feature_commit.commit_time = 1000100
  144. feature_commit.author_time = 1000100
  145. feature_commit.commit_timezone = 0
  146. feature_commit.author_timezone = 0
  147. self.repo.object_store.add_object(feature_commit)
  148. self.repo.refs[b"refs/heads/feature"] = feature_commit.id
  149. # Create main branch with conflicting change
  150. main_blob = Blob.from_string(b"Main change to file\n")
  151. self.repo.object_store.add_object(main_blob)
  152. main_tree = Tree()
  153. main_tree.add(b"file.txt", 0o100644, main_blob.id)
  154. self.repo.object_store.add_object(main_tree)
  155. main_commit = Commit()
  156. main_commit.tree = main_tree.id
  157. main_commit.parents = [self.initial_commit.id]
  158. main_commit.message = b"Main change"
  159. main_commit.committer = b"Test User <test@example.com>"
  160. main_commit.author = b"Test User <test@example.com>"
  161. main_commit.commit_time = 1000200
  162. main_commit.author_time = 1000200
  163. main_commit.commit_timezone = 0
  164. main_commit.author_timezone = 0
  165. self.repo.object_store.add_object(main_commit)
  166. self.repo.refs[b"refs/heads/master"] = main_commit.id
  167. # Attempt rebase - should fail with conflicts
  168. with self.assertRaises(RebaseConflict) as cm:
  169. rebase(self.repo, b"refs/heads/master", branch=b"refs/heads/feature")
  170. self.assertIn(b"file.txt", cm.exception.conflicted_files)
  171. def test_abort_rebase(self):
  172. """Test aborting a rebase."""
  173. self._setup_initial_commit()
  174. # Set up branches similar to simple rebase test
  175. feature_blob = Blob.from_string(b"Feature content\n")
  176. self.repo.object_store.add_object(feature_blob)
  177. feature_tree = Tree()
  178. feature_tree.add(b"feature.txt", 0o100644, feature_blob.id)
  179. feature_tree.add(
  180. b"file.txt", 0o100644, self.repo[self.initial_commit.tree][b"file.txt"][1]
  181. )
  182. self.repo.object_store.add_object(feature_tree)
  183. feature_commit = Commit()
  184. feature_commit.tree = feature_tree.id
  185. feature_commit.parents = [self.initial_commit.id]
  186. feature_commit.message = b"Add feature"
  187. feature_commit.committer = b"Test User <test@example.com>"
  188. feature_commit.author = b"Test User <test@example.com>"
  189. feature_commit.commit_time = 1000100
  190. feature_commit.author_time = 1000100
  191. feature_commit.commit_timezone = 0
  192. feature_commit.author_timezone = 0
  193. self.repo.object_store.add_object(feature_commit)
  194. self.repo.refs[b"refs/heads/feature"] = feature_commit.id
  195. self.repo.refs.set_symbolic_ref(b"HEAD", b"refs/heads/feature")
  196. # Start rebase
  197. rebaser = Rebaser(self.repo)
  198. rebaser.start(b"refs/heads/master")
  199. # Abort rebase
  200. rebaser.abort()
  201. # Check that HEAD is restored
  202. self.assertEqual(self.repo.refs.read_ref(b"HEAD"), b"ref: refs/heads/feature")
  203. self.assertEqual(self.repo.refs[b"refs/heads/feature"], feature_commit.id)
  204. # Check that REBASE_HEAD is cleaned up
  205. self.assertNotIn(b"REBASE_HEAD", self.repo.refs)
  206. def test_rebase_no_commits(self):
  207. """Test rebase when already up to date."""
  208. self._setup_initial_commit()
  209. # Both branches point to same commit
  210. self.repo.refs[b"refs/heads/feature"] = self.initial_commit.id
  211. self.repo.refs.set_symbolic_ref(b"HEAD", b"refs/heads/feature")
  212. # Perform rebase
  213. result = rebase(self.repo, b"refs/heads/master")
  214. # Should return empty list (no new commits)
  215. self.assertEqual(result, [])
  216. def test_rebase_onto(self):
  217. """Test rebase with --onto option."""
  218. self._setup_initial_commit()
  219. # Create a chain of commits: initial -> A -> B -> C
  220. blob_a = Blob.from_string(b"Commit A\n")
  221. self.repo.object_store.add_object(blob_a)
  222. tree_a = Tree()
  223. tree_a.add(b"a.txt", 0o100644, blob_a.id)
  224. tree_a.add(
  225. b"file.txt", 0o100644, self.repo[self.initial_commit.tree][b"file.txt"][1]
  226. )
  227. self.repo.object_store.add_object(tree_a)
  228. commit_a = make_commit(
  229. id=b"a" * 40,
  230. tree=tree_a.id,
  231. parents=[self.initial_commit.id],
  232. message=b"Commit A",
  233. committer=b"Test User <test@example.com>",
  234. author=b"Test User <test@example.com>",
  235. commit_time=1000100,
  236. author_time=1000100,
  237. )
  238. self.repo.object_store.add_object(commit_a)
  239. blob_b = Blob.from_string(b"Commit B\n")
  240. self.repo.object_store.add_object(blob_b)
  241. tree_b = Tree()
  242. tree_b.add(b"b.txt", 0o100644, blob_b.id)
  243. tree_b.add(b"a.txt", 0o100644, blob_a.id)
  244. tree_b.add(
  245. b"file.txt", 0o100644, self.repo[self.initial_commit.tree][b"file.txt"][1]
  246. )
  247. self.repo.object_store.add_object(tree_b)
  248. commit_b = make_commit(
  249. id=b"b" * 40,
  250. tree=tree_b.id,
  251. parents=[commit_a.id],
  252. message=b"Commit B",
  253. committer=b"Test User <test@example.com>",
  254. author=b"Test User <test@example.com>",
  255. commit_time=1000200,
  256. author_time=1000200,
  257. )
  258. self.repo.object_store.add_object(commit_b)
  259. blob_c = Blob.from_string(b"Commit C\n")
  260. self.repo.object_store.add_object(blob_c)
  261. tree_c = Tree()
  262. tree_c.add(b"c.txt", 0o100644, blob_c.id)
  263. tree_c.add(b"b.txt", 0o100644, blob_b.id)
  264. tree_c.add(b"a.txt", 0o100644, blob_a.id)
  265. tree_c.add(
  266. b"file.txt", 0o100644, self.repo[self.initial_commit.tree][b"file.txt"][1]
  267. )
  268. self.repo.object_store.add_object(tree_c)
  269. commit_c = make_commit(
  270. id=b"c" * 40,
  271. tree=tree_c.id,
  272. parents=[commit_b.id],
  273. message=b"Commit C",
  274. committer=b"Test User <test@example.com>",
  275. author=b"Test User <test@example.com>",
  276. commit_time=1000300,
  277. author_time=1000300,
  278. )
  279. self.repo.object_store.add_object(commit_c)
  280. # Create separate branch at commit A
  281. self.repo.refs[b"refs/heads/topic"] = commit_c.id
  282. self.repo.refs[b"refs/heads/newbase"] = commit_a.id
  283. self.repo.refs.set_symbolic_ref(b"HEAD", b"refs/heads/topic")
  284. # Rebase B and C onto initial commit (skipping A)
  285. rebaser = Rebaser(self.repo)
  286. commits = rebaser.start(
  287. upstream=commit_a.id,
  288. onto=self.initial_commit.id,
  289. branch=b"refs/heads/topic",
  290. )
  291. # Should rebase commits B and C
  292. self.assertEqual(len(commits), 2)
  293. self.assertEqual(commits[0].id, commit_b.id)
  294. self.assertEqual(commits[1].id, commit_c.id)
  295. # Continue rebase
  296. result = rebaser.continue_()
  297. self.assertIsNone(result)
  298. # Check result
  299. new_head = self.repo.refs[b"refs/heads/topic"]
  300. new_c = self.repo[new_head]
  301. new_b = self.repo[new_c.parents[0]]
  302. # B should now have initial commit as parent (not A)
  303. self.assertEqual(new_b.parents, [self.initial_commit.id])
  304. # Trees should still have b.txt and c.txt but not a.txt
  305. new_b_tree = self.repo[new_b.tree]
  306. self.assertIn(b"b.txt", new_b_tree)
  307. self.assertNotIn(b"a.txt", new_b_tree)
  308. new_c_tree = self.repo[new_c.tree]
  309. self.assertIn(b"c.txt", new_c_tree)
  310. self.assertIn(b"b.txt", new_c_tree)
  311. self.assertNotIn(b"a.txt", new_c_tree)
  312. class RebasePorcelainTestCase(TestCase):
  313. """Tests for the porcelain rebase function."""
  314. def setUp(self):
  315. """Set up test repository."""
  316. super().setUp()
  317. self.test_dir = tempfile.mkdtemp()
  318. self.repo = Repo.init(self.test_dir)
  319. # Create initial commit
  320. with open(os.path.join(self.test_dir, "README.md"), "wb") as f:
  321. f.write(b"# Test Repository\n")
  322. self.repo.stage(["README.md"])
  323. self.initial_commit = self.repo.do_commit(
  324. b"Initial commit",
  325. committer=b"Test User <test@example.com>",
  326. author=b"Test User <test@example.com>",
  327. )
  328. def tearDown(self):
  329. """Clean up test directory."""
  330. import shutil
  331. shutil.rmtree(self.test_dir)
  332. def test_porcelain_rebase(self):
  333. """Test rebase through porcelain interface."""
  334. from dulwich import porcelain
  335. # Create and checkout feature branch
  336. self.repo.refs[b"refs/heads/feature"] = self.initial_commit
  337. porcelain.checkout_branch(self.repo, "feature")
  338. # Add commit to feature branch
  339. with open(os.path.join(self.test_dir, "feature.txt"), "wb") as f:
  340. f.write(b"Feature file\n")
  341. porcelain.add(self.repo, ["feature.txt"])
  342. porcelain.commit(
  343. self.repo,
  344. message="Add feature",
  345. author="Test User <test@example.com>",
  346. committer="Test User <test@example.com>",
  347. )
  348. # Switch to main and add different commit
  349. porcelain.checkout_branch(self.repo, "master")
  350. with open(os.path.join(self.test_dir, "main.txt"), "wb") as f:
  351. f.write(b"Main file\n")
  352. porcelain.add(self.repo, ["main.txt"])
  353. porcelain.commit(
  354. self.repo,
  355. message="Main update",
  356. author="Test User <test@example.com>",
  357. committer="Test User <test@example.com>",
  358. )
  359. # Switch back to feature and rebase
  360. porcelain.checkout_branch(self.repo, "feature")
  361. # Perform rebase
  362. new_shas = porcelain.rebase(self.repo, "master")
  363. # Should have rebased one commit
  364. self.assertEqual(len(new_shas), 1)
  365. # Check that the rebased commit has the correct parent and tree
  366. feature_head = self.repo.refs[b"refs/heads/feature"]
  367. feature_commit_obj = self.repo[feature_head]
  368. # Should have master as parent
  369. master_head = self.repo.refs[b"refs/heads/master"]
  370. self.assertEqual(feature_commit_obj.parents, [master_head])
  371. # Tree should have both files
  372. tree = self.repo[feature_commit_obj.tree]
  373. self.assertIn(b"feature.txt", tree)
  374. self.assertIn(b"main.txt", tree)
  375. self.assertIn(b"README.md", tree)