test_porcelain_merge.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474
  1. # test_porcelain_merge.py -- Tests for porcelain merge functionality
  2. # Copyright (C) 2024 Jelmer Vernooij <jelmer@jelmer.uk>
  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 porcelain merge functionality."""
  22. import os
  23. import tempfile
  24. import unittest
  25. from dulwich import porcelain
  26. from dulwich.repo import Repo
  27. from . import TestCase
  28. class PorcelainMergeTests(TestCase):
  29. """Tests for the porcelain merge functionality."""
  30. def test_merge_fast_forward(self):
  31. """Test fast-forward merge."""
  32. with tempfile.TemporaryDirectory() as tmpdir:
  33. # Initialize repo
  34. porcelain.init(tmpdir)
  35. # Create initial commit
  36. with open(os.path.join(tmpdir, "file1.txt"), "w") as f:
  37. f.write("Initial content\n")
  38. porcelain.add(tmpdir, paths=["file1.txt"])
  39. porcelain.commit(tmpdir, message=b"Initial commit")
  40. # Create a branch
  41. porcelain.branch_create(tmpdir, "feature")
  42. porcelain.checkout_branch(tmpdir, "feature")
  43. # Add a file on feature branch
  44. with open(os.path.join(tmpdir, "file2.txt"), "w") as f:
  45. f.write("Feature content\n")
  46. porcelain.add(tmpdir, paths=["file2.txt"])
  47. feature_commit = porcelain.commit(tmpdir, message=b"Add feature")
  48. # Go back to master
  49. porcelain.checkout_branch(tmpdir, "master")
  50. # Merge feature branch (should fast-forward)
  51. merge_commit, conflicts = porcelain.merge(tmpdir, "feature")
  52. self.assertEqual(merge_commit, feature_commit)
  53. self.assertEqual(conflicts, [])
  54. # Check that file2.txt exists
  55. self.assertTrue(os.path.exists(os.path.join(tmpdir, "file2.txt")))
  56. def test_merge_already_up_to_date(self):
  57. """Test merge when already up to date."""
  58. with tempfile.TemporaryDirectory() as tmpdir:
  59. # Initialize repo
  60. porcelain.init(tmpdir)
  61. # Create initial commit
  62. with open(os.path.join(tmpdir, "file1.txt"), "w") as f:
  63. f.write("Initial content\n")
  64. porcelain.add(tmpdir, paths=["file1.txt"])
  65. porcelain.commit(tmpdir, message=b"Initial commit")
  66. # Try to merge the same commit
  67. merge_commit, conflicts = porcelain.merge(tmpdir, "HEAD")
  68. self.assertIsNone(merge_commit)
  69. self.assertEqual(conflicts, [])
  70. def test_merge_no_ff(self):
  71. """Test merge with --no-ff flag."""
  72. with tempfile.TemporaryDirectory() as tmpdir:
  73. # Initialize repo
  74. porcelain.init(tmpdir)
  75. # Create initial commit
  76. with open(os.path.join(tmpdir, "file1.txt"), "w") as f:
  77. f.write("Initial content\n")
  78. porcelain.add(tmpdir, paths=["file1.txt"])
  79. porcelain.commit(tmpdir, message=b"Initial commit")
  80. # Create a branch
  81. porcelain.branch_create(tmpdir, "feature")
  82. porcelain.checkout_branch(tmpdir, "feature")
  83. # Add a file on feature branch
  84. with open(os.path.join(tmpdir, "file2.txt"), "w") as f:
  85. f.write("Feature content\n")
  86. porcelain.add(tmpdir, paths=["file2.txt"])
  87. feature_commit = porcelain.commit(tmpdir, message=b"Add feature")
  88. # Go back to master
  89. porcelain.checkout_branch(tmpdir, "master")
  90. # Merge feature branch with no-ff
  91. merge_commit, conflicts = porcelain.merge(tmpdir, "feature", no_ff=True)
  92. # Should create a new merge commit
  93. self.assertIsNotNone(merge_commit)
  94. self.assertNotEqual(merge_commit, feature_commit)
  95. self.assertEqual(conflicts, [])
  96. # Check that it's a merge commit with two parents
  97. with Repo(tmpdir) as repo:
  98. commit = repo[merge_commit]
  99. self.assertEqual(len(commit.parents), 2)
  100. def test_merge_three_way(self):
  101. """Test three-way merge without conflicts."""
  102. with tempfile.TemporaryDirectory() as tmpdir:
  103. # Initialize repo
  104. porcelain.init(tmpdir)
  105. # Create initial commit
  106. with open(os.path.join(tmpdir, "file1.txt"), "w") as f:
  107. f.write("Initial content\n")
  108. with open(os.path.join(tmpdir, "file2.txt"), "w") as f:
  109. f.write("Initial file2\n")
  110. porcelain.add(tmpdir, paths=["file1.txt", "file2.txt"])
  111. porcelain.commit(tmpdir, message=b"Initial commit")
  112. # Create a branch and modify file1
  113. porcelain.branch_create(tmpdir, "feature")
  114. porcelain.checkout_branch(tmpdir, "feature")
  115. with open(os.path.join(tmpdir, "file1.txt"), "w") as f:
  116. f.write("Feature content\n")
  117. porcelain.add(tmpdir, paths=["file1.txt"])
  118. porcelain.commit(tmpdir, message=b"Modify file1 in feature")
  119. # Go back to master and modify file2
  120. porcelain.checkout_branch(tmpdir, "master")
  121. with open(os.path.join(tmpdir, "file2.txt"), "w") as f:
  122. f.write("Master file2\n")
  123. porcelain.add(tmpdir, paths=["file2.txt"])
  124. porcelain.commit(tmpdir, message=b"Modify file2 in master")
  125. # Merge feature branch
  126. merge_commit, conflicts = porcelain.merge(tmpdir, "feature")
  127. self.assertIsNotNone(merge_commit)
  128. self.assertEqual(conflicts, [])
  129. # Check both modifications are present
  130. with open(os.path.join(tmpdir, "file1.txt")) as f:
  131. self.assertEqual(f.read(), "Feature content\n")
  132. with open(os.path.join(tmpdir, "file2.txt")) as f:
  133. self.assertEqual(f.read(), "Master file2\n")
  134. def test_merge_with_conflicts(self):
  135. """Test merge with conflicts."""
  136. with tempfile.TemporaryDirectory() as tmpdir:
  137. # Initialize repo
  138. porcelain.init(tmpdir)
  139. # Create initial commit
  140. with open(os.path.join(tmpdir, "file1.txt"), "w") as f:
  141. f.write("Initial content\n")
  142. porcelain.add(tmpdir, paths=["file1.txt"])
  143. porcelain.commit(tmpdir, message=b"Initial commit")
  144. # Create a branch and modify file1
  145. porcelain.branch_create(tmpdir, "feature")
  146. porcelain.checkout_branch(tmpdir, "feature")
  147. with open(os.path.join(tmpdir, "file1.txt"), "w") as f:
  148. f.write("Feature content\n")
  149. porcelain.add(tmpdir, paths=["file1.txt"])
  150. porcelain.commit(tmpdir, message=b"Modify file1 in feature")
  151. # Go back to master and modify file1 differently
  152. porcelain.checkout_branch(tmpdir, "master")
  153. with open(os.path.join(tmpdir, "file1.txt"), "w") as f:
  154. f.write("Master content\n")
  155. porcelain.add(tmpdir, paths=["file1.txt"])
  156. porcelain.commit(tmpdir, message=b"Modify file1 in master")
  157. # Merge feature branch - should have conflicts
  158. merge_commit, conflicts = porcelain.merge(tmpdir, "feature")
  159. self.assertIsNone(merge_commit)
  160. self.assertEqual(len(conflicts), 1)
  161. self.assertEqual(conflicts[0], b"file1.txt")
  162. # Check conflict markers in file
  163. with open(os.path.join(tmpdir, "file1.txt"), "rb") as f:
  164. content = f.read()
  165. self.assertIn(b"<<<<<<< ours", content)
  166. self.assertIn(b"=======", content)
  167. self.assertIn(b">>>>>>> theirs", content)
  168. def test_merge_no_commit(self):
  169. """Test merge with no_commit flag."""
  170. with tempfile.TemporaryDirectory() as tmpdir:
  171. # Initialize repo
  172. porcelain.init(tmpdir)
  173. # Create initial commit
  174. with open(os.path.join(tmpdir, "file1.txt"), "w") as f:
  175. f.write("Initial content\n")
  176. porcelain.add(tmpdir, paths=["file1.txt"])
  177. porcelain.commit(tmpdir, message=b"Initial commit")
  178. # Create a branch
  179. porcelain.branch_create(tmpdir, "feature")
  180. porcelain.checkout_branch(tmpdir, "feature")
  181. # Add a file on feature branch
  182. with open(os.path.join(tmpdir, "file2.txt"), "w") as f:
  183. f.write("Feature content\n")
  184. porcelain.add(tmpdir, paths=["file2.txt"])
  185. porcelain.commit(tmpdir, message=b"Add feature")
  186. # Go back to master and add another file
  187. porcelain.checkout_branch(tmpdir, "master")
  188. with open(os.path.join(tmpdir, "file3.txt"), "w") as f:
  189. f.write("Master content\n")
  190. porcelain.add(tmpdir, paths=["file3.txt"])
  191. master_commit = porcelain.commit(tmpdir, message=b"Add file3")
  192. # Merge feature branch with no_commit
  193. merge_commit, conflicts = porcelain.merge(tmpdir, "feature", no_commit=True)
  194. self.assertIsNone(merge_commit)
  195. self.assertEqual(conflicts, [])
  196. # Check that files are merged but no commit was created
  197. self.assertTrue(os.path.exists(os.path.join(tmpdir, "file2.txt")))
  198. self.assertTrue(os.path.exists(os.path.join(tmpdir, "file3.txt")))
  199. # HEAD should still point to master_commit
  200. with Repo(tmpdir) as repo:
  201. self.assertEqual(repo.refs[b"HEAD"], master_commit)
  202. def test_merge_no_head(self):
  203. """Test merge with no HEAD reference."""
  204. with tempfile.TemporaryDirectory() as tmpdir:
  205. # Initialize repo without any commits
  206. porcelain.init(tmpdir)
  207. # Try to merge - should fail with no HEAD
  208. self.assertRaises(porcelain.Error, porcelain.merge, tmpdir, "nonexistent")
  209. def test_merge_invalid_commit(self):
  210. """Test merge with invalid commit reference."""
  211. with tempfile.TemporaryDirectory() as tmpdir:
  212. # Initialize repo
  213. porcelain.init(tmpdir)
  214. # Create initial commit
  215. with open(os.path.join(tmpdir, "file1.txt"), "w") as f:
  216. f.write("Initial content\n")
  217. porcelain.add(tmpdir, paths=["file1.txt"])
  218. porcelain.commit(tmpdir, message=b"Initial commit")
  219. # Try to merge nonexistent commit
  220. self.assertRaises(porcelain.Error, porcelain.merge, tmpdir, "nonexistent")
  221. class PorcelainMergeTreeTests(TestCase):
  222. """Tests for the porcelain merge_tree functionality."""
  223. def test_merge_tree_no_conflicts(self):
  224. """Test merge_tree with no conflicts."""
  225. with tempfile.TemporaryDirectory() as tmpdir:
  226. # Initialize repo
  227. porcelain.init(tmpdir)
  228. repo = Repo(tmpdir)
  229. # Create base tree
  230. with open(os.path.join(tmpdir, "file1.txt"), "w") as f:
  231. f.write("Base content\n")
  232. porcelain.add(tmpdir, paths=["file1.txt"])
  233. base_commit = porcelain.commit(tmpdir, message=b"Base commit")
  234. # Create our branch
  235. porcelain.branch_create(tmpdir, "ours")
  236. porcelain.checkout_branch(tmpdir, "ours")
  237. with open(os.path.join(tmpdir, "file1.txt"), "w") as f:
  238. f.write("Our content\n")
  239. with open(os.path.join(tmpdir, "file2.txt"), "w") as f:
  240. f.write("Our new file\n")
  241. porcelain.add(tmpdir, paths=["file1.txt", "file2.txt"])
  242. our_commit = porcelain.commit(tmpdir, message=b"Our commit")
  243. # Create their branch
  244. porcelain.checkout_branch(tmpdir, b"master")
  245. porcelain.branch_create(tmpdir, "theirs")
  246. porcelain.checkout_branch(tmpdir, "theirs")
  247. with open(os.path.join(tmpdir, "file3.txt"), "w") as f:
  248. f.write("Their new file\n")
  249. porcelain.add(tmpdir, paths=["file3.txt"])
  250. their_commit = porcelain.commit(tmpdir, message=b"Their commit")
  251. # Perform merge_tree
  252. merged_tree_id, conflicts = porcelain.merge_tree(
  253. tmpdir, base_commit, our_commit, their_commit
  254. )
  255. # Should have no conflicts
  256. self.assertEqual(conflicts, [])
  257. # Check merged tree contains all files
  258. merged_tree = repo[merged_tree_id]
  259. self.assertIn(b"file1.txt", merged_tree)
  260. self.assertIn(b"file2.txt", merged_tree)
  261. self.assertIn(b"file3.txt", merged_tree)
  262. def test_merge_tree_with_conflicts(self):
  263. """Test merge_tree with conflicts."""
  264. with tempfile.TemporaryDirectory() as tmpdir:
  265. # Initialize repo
  266. porcelain.init(tmpdir)
  267. repo = Repo(tmpdir)
  268. # Create base tree
  269. with open(os.path.join(tmpdir, "file1.txt"), "w") as f:
  270. f.write("Base content\n")
  271. porcelain.add(tmpdir, paths=["file1.txt"])
  272. base_commit = porcelain.commit(tmpdir, message=b"Base commit")
  273. # Create our branch with changes
  274. porcelain.branch_create(tmpdir, "ours")
  275. porcelain.checkout_branch(tmpdir, "ours")
  276. with open(os.path.join(tmpdir, "file1.txt"), "w") as f:
  277. f.write("Our content\n")
  278. porcelain.add(tmpdir, paths=["file1.txt"])
  279. our_commit = porcelain.commit(tmpdir, message=b"Our commit")
  280. # Create their branch with conflicting changes
  281. porcelain.checkout_branch(tmpdir, b"master")
  282. porcelain.branch_create(tmpdir, "theirs")
  283. porcelain.checkout_branch(tmpdir, "theirs")
  284. with open(os.path.join(tmpdir, "file1.txt"), "w") as f:
  285. f.write("Their content\n")
  286. porcelain.add(tmpdir, paths=["file1.txt"])
  287. their_commit = porcelain.commit(tmpdir, message=b"Their commit")
  288. # Perform merge_tree
  289. merged_tree_id, conflicts = porcelain.merge_tree(
  290. tmpdir, base_commit, our_commit, their_commit
  291. )
  292. # Should have conflicts
  293. self.assertEqual(conflicts, [b"file1.txt"])
  294. # Check merged tree exists and contains conflict markers
  295. merged_tree = repo[merged_tree_id]
  296. self.assertIn(b"file1.txt", merged_tree)
  297. # Get the merged blob content
  298. file_mode, file_sha = merged_tree[b"file1.txt"]
  299. merged_blob = repo[file_sha]
  300. content = merged_blob.data
  301. # Should contain conflict markers
  302. self.assertIn(b"<<<<<<< ours", content)
  303. self.assertIn(b"=======", content)
  304. self.assertIn(b">>>>>>> theirs", content)
  305. def test_merge_tree_no_base(self):
  306. """Test merge_tree without a base commit."""
  307. with tempfile.TemporaryDirectory() as tmpdir:
  308. # Initialize repo
  309. porcelain.init(tmpdir)
  310. repo = Repo(tmpdir)
  311. # Create our tree
  312. with open(os.path.join(tmpdir, "file1.txt"), "w") as f:
  313. f.write("Our content\n")
  314. porcelain.add(tmpdir, paths=["file1.txt"])
  315. our_commit = porcelain.commit(tmpdir, message=b"Our commit")
  316. # Create their tree (independent)
  317. os.remove(os.path.join(tmpdir, "file1.txt"))
  318. with open(os.path.join(tmpdir, "file2.txt"), "w") as f:
  319. f.write("Their content\n")
  320. porcelain.add(tmpdir, paths=["file2.txt"])
  321. their_commit = porcelain.commit(tmpdir, message=b"Their commit")
  322. # Perform merge_tree without base
  323. merged_tree_id, conflicts = porcelain.merge_tree(
  324. tmpdir, None, our_commit, their_commit
  325. )
  326. # Should have no conflicts (different files)
  327. self.assertEqual(conflicts, [])
  328. # Check merged tree contains both files
  329. merged_tree = repo[merged_tree_id]
  330. self.assertIn(b"file1.txt", merged_tree)
  331. self.assertIn(b"file2.txt", merged_tree)
  332. def test_merge_tree_with_tree_objects(self):
  333. """Test merge_tree with tree objects instead of commits."""
  334. with tempfile.TemporaryDirectory() as tmpdir:
  335. # Initialize repo
  336. porcelain.init(tmpdir)
  337. repo = Repo(tmpdir)
  338. # Create base tree
  339. with open(os.path.join(tmpdir, "file1.txt"), "w") as f:
  340. f.write("Base content\n")
  341. porcelain.add(tmpdir, paths=["file1.txt"])
  342. base_commit_id = porcelain.commit(tmpdir, message=b"Base commit")
  343. base_tree_id = repo[base_commit_id].tree
  344. # Create our tree
  345. with open(os.path.join(tmpdir, "file1.txt"), "w") as f:
  346. f.write("Our content\n")
  347. porcelain.add(tmpdir, paths=["file1.txt"])
  348. our_commit_id = porcelain.commit(tmpdir, message=b"Our commit")
  349. our_tree_id = repo[our_commit_id].tree
  350. # Create their tree
  351. with open(os.path.join(tmpdir, "file1.txt"), "w") as f:
  352. f.write("Their content\n")
  353. porcelain.add(tmpdir, paths=["file1.txt"])
  354. their_commit_id = porcelain.commit(tmpdir, message=b"Their commit")
  355. their_tree_id = repo[their_commit_id].tree
  356. # Perform merge_tree with tree SHAs
  357. merged_tree_id, conflicts = porcelain.merge_tree(
  358. tmpdir,
  359. base_tree_id if base_tree_id else None,
  360. our_tree_id,
  361. their_tree_id,
  362. )
  363. # Should have conflicts
  364. self.assertEqual(conflicts, [b"file1.txt"])
  365. def test_merge_tree_invalid_object(self):
  366. """Test merge_tree with invalid object reference."""
  367. with tempfile.TemporaryDirectory() as tmpdir:
  368. # Initialize repo
  369. porcelain.init(tmpdir)
  370. # Create a commit
  371. with open(os.path.join(tmpdir, "file1.txt"), "w") as f:
  372. f.write("Content\n")
  373. porcelain.add(tmpdir, paths=["file1.txt"])
  374. commit_id = porcelain.commit(tmpdir, message=b"Commit")
  375. # Try to merge with nonexistent object
  376. self.assertRaises(
  377. KeyError,
  378. porcelain.merge_tree,
  379. tmpdir,
  380. None,
  381. commit_id,
  382. "0" * 40, # Invalid SHA
  383. )
  384. if __name__ == "__main__":
  385. unittest.main()