test_porcelain_merge.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478
  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 published 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(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(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(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(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(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(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(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(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(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(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. self.addCleanup(repo.close)
  230. # Create base tree
  231. with open(os.path.join(tmpdir, "file1.txt"), "w") as f:
  232. f.write("Base content\n")
  233. porcelain.add(tmpdir, paths=["file1.txt"])
  234. base_commit = porcelain.commit(tmpdir, message=b"Base commit")
  235. # Create our branch
  236. porcelain.branch_create(tmpdir, "ours")
  237. porcelain.checkout(tmpdir, "ours")
  238. with open(os.path.join(tmpdir, "file1.txt"), "w") as f:
  239. f.write("Our content\n")
  240. with open(os.path.join(tmpdir, "file2.txt"), "w") as f:
  241. f.write("Our new file\n")
  242. porcelain.add(tmpdir, paths=["file1.txt", "file2.txt"])
  243. our_commit = porcelain.commit(tmpdir, message=b"Our commit")
  244. # Create their branch
  245. porcelain.checkout(tmpdir, b"master")
  246. porcelain.branch_create(tmpdir, "theirs")
  247. porcelain.checkout(tmpdir, "theirs")
  248. with open(os.path.join(tmpdir, "file3.txt"), "w") as f:
  249. f.write("Their new file\n")
  250. porcelain.add(tmpdir, paths=["file3.txt"])
  251. their_commit = porcelain.commit(tmpdir, message=b"Their commit")
  252. # Perform merge_tree
  253. merged_tree_id, conflicts = porcelain.merge_tree(
  254. tmpdir, base_commit, our_commit, their_commit
  255. )
  256. # Should have no conflicts
  257. self.assertEqual(conflicts, [])
  258. # Check merged tree contains all files
  259. merged_tree = repo[merged_tree_id]
  260. self.assertIn(b"file1.txt", merged_tree)
  261. self.assertIn(b"file2.txt", merged_tree)
  262. self.assertIn(b"file3.txt", merged_tree)
  263. def test_merge_tree_with_conflicts(self):
  264. """Test merge_tree with conflicts."""
  265. with tempfile.TemporaryDirectory() as tmpdir:
  266. # Initialize repo
  267. porcelain.init(tmpdir)
  268. repo = Repo(tmpdir)
  269. self.addCleanup(repo.close)
  270. # Create base tree
  271. with open(os.path.join(tmpdir, "file1.txt"), "w") as f:
  272. f.write("Base content\n")
  273. porcelain.add(tmpdir, paths=["file1.txt"])
  274. base_commit = porcelain.commit(tmpdir, message=b"Base commit")
  275. # Create our branch with changes
  276. porcelain.branch_create(tmpdir, "ours")
  277. porcelain.checkout(tmpdir, "ours")
  278. with open(os.path.join(tmpdir, "file1.txt"), "w") as f:
  279. f.write("Our content\n")
  280. porcelain.add(tmpdir, paths=["file1.txt"])
  281. our_commit = porcelain.commit(tmpdir, message=b"Our commit")
  282. # Create their branch with conflicting changes
  283. porcelain.checkout(tmpdir, b"master")
  284. porcelain.branch_create(tmpdir, "theirs")
  285. porcelain.checkout(tmpdir, "theirs")
  286. with open(os.path.join(tmpdir, "file1.txt"), "w") as f:
  287. f.write("Their content\n")
  288. porcelain.add(tmpdir, paths=["file1.txt"])
  289. their_commit = porcelain.commit(tmpdir, message=b"Their commit")
  290. # Perform merge_tree
  291. merged_tree_id, conflicts = porcelain.merge_tree(
  292. tmpdir, base_commit, our_commit, their_commit
  293. )
  294. # Should have conflicts
  295. self.assertEqual(conflicts, [b"file1.txt"])
  296. # Check merged tree exists and contains conflict markers
  297. merged_tree = repo[merged_tree_id]
  298. self.assertIn(b"file1.txt", merged_tree)
  299. # Get the merged blob content
  300. file_mode, file_sha = merged_tree[b"file1.txt"]
  301. merged_blob = repo[file_sha]
  302. content = merged_blob.data
  303. # Should contain conflict markers
  304. self.assertIn(b"<<<<<<< ours", content)
  305. self.assertIn(b"=======", content)
  306. self.assertIn(b">>>>>>> theirs", content)
  307. def test_merge_tree_no_base(self):
  308. """Test merge_tree without a base commit."""
  309. with tempfile.TemporaryDirectory() as tmpdir:
  310. # Initialize repo
  311. porcelain.init(tmpdir)
  312. repo = Repo(tmpdir)
  313. self.addCleanup(repo.close)
  314. # Create our tree
  315. with open(os.path.join(tmpdir, "file1.txt"), "w") as f:
  316. f.write("Our content\n")
  317. porcelain.add(tmpdir, paths=["file1.txt"])
  318. our_commit = porcelain.commit(tmpdir, message=b"Our commit")
  319. # Create their tree (independent)
  320. os.remove(os.path.join(tmpdir, "file1.txt"))
  321. with open(os.path.join(tmpdir, "file2.txt"), "w") as f:
  322. f.write("Their content\n")
  323. porcelain.add(tmpdir, paths=["file2.txt"])
  324. their_commit = porcelain.commit(tmpdir, message=b"Their commit")
  325. # Perform merge_tree without base
  326. merged_tree_id, conflicts = porcelain.merge_tree(
  327. tmpdir, None, our_commit, their_commit
  328. )
  329. # Should have no conflicts (different files)
  330. self.assertEqual(conflicts, [])
  331. # Check merged tree contains both files
  332. merged_tree = repo[merged_tree_id]
  333. self.assertIn(b"file1.txt", merged_tree)
  334. self.assertIn(b"file2.txt", merged_tree)
  335. def test_merge_tree_with_tree_objects(self):
  336. """Test merge_tree with tree objects instead of commits."""
  337. with tempfile.TemporaryDirectory() as tmpdir:
  338. # Initialize repo
  339. porcelain.init(tmpdir)
  340. repo = Repo(tmpdir)
  341. self.addCleanup(repo.close)
  342. # Create base tree
  343. with open(os.path.join(tmpdir, "file1.txt"), "w") as f:
  344. f.write("Base content\n")
  345. porcelain.add(tmpdir, paths=["file1.txt"])
  346. base_commit_id = porcelain.commit(tmpdir, message=b"Base commit")
  347. base_tree_id = repo[base_commit_id].tree
  348. # Create our tree
  349. with open(os.path.join(tmpdir, "file1.txt"), "w") as f:
  350. f.write("Our content\n")
  351. porcelain.add(tmpdir, paths=["file1.txt"])
  352. our_commit_id = porcelain.commit(tmpdir, message=b"Our commit")
  353. our_tree_id = repo[our_commit_id].tree
  354. # Create their tree
  355. with open(os.path.join(tmpdir, "file1.txt"), "w") as f:
  356. f.write("Their content\n")
  357. porcelain.add(tmpdir, paths=["file1.txt"])
  358. their_commit_id = porcelain.commit(tmpdir, message=b"Their commit")
  359. their_tree_id = repo[their_commit_id].tree
  360. # Perform merge_tree with tree SHAs
  361. merged_tree_id, conflicts = porcelain.merge_tree(
  362. tmpdir,
  363. base_tree_id if base_tree_id else None,
  364. our_tree_id,
  365. their_tree_id,
  366. )
  367. # Should have conflicts
  368. self.assertEqual(conflicts, [b"file1.txt"])
  369. def test_merge_tree_invalid_object(self):
  370. """Test merge_tree with invalid object reference."""
  371. with tempfile.TemporaryDirectory() as tmpdir:
  372. # Initialize repo
  373. porcelain.init(tmpdir)
  374. # Create a commit
  375. with open(os.path.join(tmpdir, "file1.txt"), "w") as f:
  376. f.write("Content\n")
  377. porcelain.add(tmpdir, paths=["file1.txt"])
  378. commit_id = porcelain.commit(tmpdir, message=b"Commit")
  379. # Try to merge with nonexistent object
  380. self.assertRaises(
  381. KeyError,
  382. porcelain.merge_tree,
  383. tmpdir,
  384. None,
  385. commit_id,
  386. "0" * 40, # Invalid SHA
  387. )
  388. if __name__ == "__main__":
  389. unittest.main()