test_merge.py 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690
  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 importlib.util
  23. import os
  24. import tempfile
  25. import unittest
  26. from dulwich import porcelain
  27. from dulwich.repo import Repo
  28. from .. import DependencyMissing, TestCase
  29. class PorcelainMergeTests(TestCase):
  30. """Tests for the porcelain merge functionality."""
  31. def test_merge_fast_forward(self):
  32. """Test fast-forward merge."""
  33. with tempfile.TemporaryDirectory() as tmpdir:
  34. # Initialize repo
  35. porcelain.init(tmpdir)
  36. # Create initial commit
  37. with open(os.path.join(tmpdir, "file1.txt"), "w") as f:
  38. f.write("Initial content\n")
  39. porcelain.add(tmpdir, paths=["file1.txt"])
  40. porcelain.commit(tmpdir, message=b"Initial commit")
  41. # Create a branch
  42. porcelain.branch_create(tmpdir, "feature")
  43. porcelain.checkout(tmpdir, "feature")
  44. # Add a file on feature branch
  45. with open(os.path.join(tmpdir, "file2.txt"), "w") as f:
  46. f.write("Feature content\n")
  47. porcelain.add(tmpdir, paths=["file2.txt"])
  48. feature_commit = porcelain.commit(tmpdir, message=b"Add feature")
  49. # Go back to master
  50. porcelain.checkout(tmpdir, "master")
  51. # Merge feature branch (should fast-forward)
  52. merge_commit, conflicts = porcelain.merge(tmpdir, "feature")
  53. self.assertEqual(merge_commit, feature_commit)
  54. self.assertEqual(conflicts, [])
  55. # Check that file2.txt exists
  56. self.assertTrue(os.path.exists(os.path.join(tmpdir, "file2.txt")))
  57. def test_merge_already_up_to_date(self):
  58. """Test merge when already up to date."""
  59. with tempfile.TemporaryDirectory() as tmpdir:
  60. # Initialize repo
  61. porcelain.init(tmpdir)
  62. # Create initial commit
  63. with open(os.path.join(tmpdir, "file1.txt"), "w") as f:
  64. f.write("Initial content\n")
  65. porcelain.add(tmpdir, paths=["file1.txt"])
  66. porcelain.commit(tmpdir, message=b"Initial commit")
  67. # Try to merge the same commit
  68. merge_commit, conflicts = porcelain.merge(tmpdir, "HEAD")
  69. self.assertIsNone(merge_commit)
  70. self.assertEqual(conflicts, [])
  71. def test_merge_no_ff(self):
  72. """Test merge with --no-ff flag."""
  73. with tempfile.TemporaryDirectory() as tmpdir:
  74. # Initialize repo
  75. porcelain.init(tmpdir)
  76. # Create initial commit
  77. with open(os.path.join(tmpdir, "file1.txt"), "w") as f:
  78. f.write("Initial content\n")
  79. porcelain.add(tmpdir, paths=["file1.txt"])
  80. porcelain.commit(tmpdir, message=b"Initial commit")
  81. # Create a branch
  82. porcelain.branch_create(tmpdir, "feature")
  83. porcelain.checkout(tmpdir, "feature")
  84. # Add a file on feature branch
  85. with open(os.path.join(tmpdir, "file2.txt"), "w") as f:
  86. f.write("Feature content\n")
  87. porcelain.add(tmpdir, paths=["file2.txt"])
  88. feature_commit = porcelain.commit(tmpdir, message=b"Add feature")
  89. # Go back to master
  90. porcelain.checkout(tmpdir, "master")
  91. # Merge feature branch with no-ff
  92. merge_commit, conflicts = porcelain.merge(tmpdir, "feature", no_ff=True)
  93. # Should create a new merge commit
  94. self.assertIsNotNone(merge_commit)
  95. self.assertNotEqual(merge_commit, feature_commit)
  96. self.assertEqual(conflicts, [])
  97. # Check that it's a merge commit with two parents
  98. with Repo(tmpdir) as repo:
  99. commit = repo[merge_commit]
  100. self.assertEqual(len(commit.parents), 2)
  101. def test_merge_three_way(self):
  102. """Test three-way merge without conflicts."""
  103. with tempfile.TemporaryDirectory() as tmpdir:
  104. # Initialize repo
  105. porcelain.init(tmpdir)
  106. # Create initial commit
  107. with open(os.path.join(tmpdir, "file1.txt"), "w") as f:
  108. f.write("Initial content\n")
  109. with open(os.path.join(tmpdir, "file2.txt"), "w") as f:
  110. f.write("Initial file2\n")
  111. porcelain.add(tmpdir, paths=["file1.txt", "file2.txt"])
  112. porcelain.commit(tmpdir, message=b"Initial commit")
  113. # Create a branch and modify file1
  114. porcelain.branch_create(tmpdir, "feature")
  115. porcelain.checkout(tmpdir, "feature")
  116. with open(os.path.join(tmpdir, "file1.txt"), "w") as f:
  117. f.write("Feature content\n")
  118. porcelain.add(tmpdir, paths=["file1.txt"])
  119. porcelain.commit(tmpdir, message=b"Modify file1 in feature")
  120. # Go back to master and modify file2
  121. porcelain.checkout(tmpdir, "master")
  122. with open(os.path.join(tmpdir, "file2.txt"), "w") as f:
  123. f.write("Master file2\n")
  124. porcelain.add(tmpdir, paths=["file2.txt"])
  125. porcelain.commit(tmpdir, message=b"Modify file2 in master")
  126. # Merge feature branch
  127. merge_commit, conflicts = porcelain.merge(tmpdir, "feature")
  128. self.assertIsNotNone(merge_commit)
  129. self.assertEqual(conflicts, [])
  130. # Check both modifications are present
  131. with open(os.path.join(tmpdir, "file1.txt")) as f:
  132. self.assertEqual(f.read(), "Feature content\n")
  133. with open(os.path.join(tmpdir, "file2.txt")) as f:
  134. self.assertEqual(f.read(), "Master file2\n")
  135. def test_merge_with_conflicts(self):
  136. # Check if merge3 module is available
  137. if importlib.util.find_spec("merge3") is None:
  138. raise DependencyMissing("merge3")
  139. """Test merge with conflicts."""
  140. with tempfile.TemporaryDirectory() as tmpdir:
  141. # Initialize repo
  142. porcelain.init(tmpdir)
  143. # Create initial commit
  144. with open(os.path.join(tmpdir, "file1.txt"), "w") as f:
  145. f.write("Initial content\n")
  146. porcelain.add(tmpdir, paths=["file1.txt"])
  147. porcelain.commit(tmpdir, message=b"Initial commit")
  148. # Create a branch and modify file1
  149. porcelain.branch_create(tmpdir, "feature")
  150. porcelain.checkout(tmpdir, "feature")
  151. with open(os.path.join(tmpdir, "file1.txt"), "w") as f:
  152. f.write("Feature content\n")
  153. porcelain.add(tmpdir, paths=["file1.txt"])
  154. porcelain.commit(tmpdir, message=b"Modify file1 in feature")
  155. # Go back to master and modify file1 differently
  156. porcelain.checkout(tmpdir, "master")
  157. with open(os.path.join(tmpdir, "file1.txt"), "w") as f:
  158. f.write("Master content\n")
  159. porcelain.add(tmpdir, paths=["file1.txt"])
  160. porcelain.commit(tmpdir, message=b"Modify file1 in master")
  161. # Merge feature branch - should have conflicts
  162. merge_commit, conflicts = porcelain.merge(tmpdir, "feature")
  163. self.assertIsNone(merge_commit)
  164. self.assertEqual(len(conflicts), 1)
  165. self.assertEqual(conflicts[0], b"file1.txt")
  166. # Check conflict markers in file
  167. with open(os.path.join(tmpdir, "file1.txt"), "rb") as f:
  168. content = f.read()
  169. self.assertIn(b"<<<<<<< ours", content)
  170. self.assertIn(b"=======", content)
  171. self.assertIn(b">>>>>>> theirs", content)
  172. def test_merge_no_commit(self):
  173. """Test merge with no_commit flag."""
  174. with tempfile.TemporaryDirectory() as tmpdir:
  175. # Initialize repo
  176. porcelain.init(tmpdir)
  177. # Create initial commit
  178. with open(os.path.join(tmpdir, "file1.txt"), "w") as f:
  179. f.write("Initial content\n")
  180. porcelain.add(tmpdir, paths=["file1.txt"])
  181. porcelain.commit(tmpdir, message=b"Initial commit")
  182. # Create a branch
  183. porcelain.branch_create(tmpdir, "feature")
  184. porcelain.checkout(tmpdir, "feature")
  185. # Add a file on feature branch
  186. with open(os.path.join(tmpdir, "file2.txt"), "w") as f:
  187. f.write("Feature content\n")
  188. porcelain.add(tmpdir, paths=["file2.txt"])
  189. porcelain.commit(tmpdir, message=b"Add feature")
  190. # Go back to master and add another file
  191. porcelain.checkout(tmpdir, "master")
  192. with open(os.path.join(tmpdir, "file3.txt"), "w") as f:
  193. f.write("Master content\n")
  194. porcelain.add(tmpdir, paths=["file3.txt"])
  195. master_commit = porcelain.commit(tmpdir, message=b"Add file3")
  196. # Merge feature branch with no_commit
  197. merge_commit, conflicts = porcelain.merge(tmpdir, "feature", no_commit=True)
  198. self.assertIsNone(merge_commit)
  199. self.assertEqual(conflicts, [])
  200. # Check that files are merged but no commit was created
  201. self.assertTrue(os.path.exists(os.path.join(tmpdir, "file2.txt")))
  202. self.assertTrue(os.path.exists(os.path.join(tmpdir, "file3.txt")))
  203. # HEAD should still point to master_commit
  204. with Repo(tmpdir) as repo:
  205. self.assertEqual(repo.refs[b"HEAD"], master_commit)
  206. def test_merge_no_head(self):
  207. """Test merge with no HEAD reference."""
  208. with tempfile.TemporaryDirectory() as tmpdir:
  209. # Initialize repo without any commits
  210. porcelain.init(tmpdir)
  211. # Try to merge - should fail with no HEAD
  212. self.assertRaises(porcelain.Error, porcelain.merge, tmpdir, "nonexistent")
  213. def test_merge_invalid_commit(self):
  214. """Test merge with invalid commit reference."""
  215. with tempfile.TemporaryDirectory() as tmpdir:
  216. # Initialize repo
  217. porcelain.init(tmpdir)
  218. # Create initial commit
  219. with open(os.path.join(tmpdir, "file1.txt"), "w") as f:
  220. f.write("Initial content\n")
  221. porcelain.add(tmpdir, paths=["file1.txt"])
  222. porcelain.commit(tmpdir, message=b"Initial commit")
  223. # Try to merge nonexistent commit
  224. self.assertRaises(porcelain.Error, porcelain.merge, tmpdir, "nonexistent")
  225. def test_octopus_merge_three_branches(self):
  226. """Test octopus merge with three branches."""
  227. with tempfile.TemporaryDirectory() as tmpdir:
  228. # Initialize repo
  229. porcelain.init(tmpdir)
  230. # Create initial commit with three files
  231. with open(os.path.join(tmpdir, "file1.txt"), "w") as f:
  232. f.write("File 1 content\n")
  233. with open(os.path.join(tmpdir, "file2.txt"), "w") as f:
  234. f.write("File 2 content\n")
  235. with open(os.path.join(tmpdir, "file3.txt"), "w") as f:
  236. f.write("File 3 content\n")
  237. porcelain.add(tmpdir, paths=["file1.txt", "file2.txt", "file3.txt"])
  238. porcelain.commit(tmpdir, message=b"Initial commit")
  239. # Create branch1 and modify file1
  240. porcelain.branch_create(tmpdir, "branch1")
  241. porcelain.checkout(tmpdir, "branch1")
  242. with open(os.path.join(tmpdir, "file1.txt"), "w") as f:
  243. f.write("Branch1 modified file1\n")
  244. porcelain.add(tmpdir, paths=["file1.txt"])
  245. porcelain.commit(tmpdir, message=b"Branch1 modifies file1")
  246. # Create branch2 and modify file2
  247. porcelain.checkout(tmpdir, "master")
  248. porcelain.branch_create(tmpdir, "branch2")
  249. porcelain.checkout(tmpdir, "branch2")
  250. with open(os.path.join(tmpdir, "file2.txt"), "w") as f:
  251. f.write("Branch2 modified file2\n")
  252. porcelain.add(tmpdir, paths=["file2.txt"])
  253. porcelain.commit(tmpdir, message=b"Branch2 modifies file2")
  254. # Create branch3 and modify file3
  255. porcelain.checkout(tmpdir, "master")
  256. porcelain.branch_create(tmpdir, "branch3")
  257. porcelain.checkout(tmpdir, "branch3")
  258. with open(os.path.join(tmpdir, "file3.txt"), "w") as f:
  259. f.write("Branch3 modified file3\n")
  260. porcelain.add(tmpdir, paths=["file3.txt"])
  261. porcelain.commit(tmpdir, message=b"Branch3 modifies file3")
  262. # Go back to master and octopus merge all three branches
  263. porcelain.checkout(tmpdir, "master")
  264. merge_commit, conflicts = porcelain.merge(
  265. tmpdir, ["branch1", "branch2", "branch3"]
  266. )
  267. # Should succeed with no conflicts
  268. self.assertIsNotNone(merge_commit)
  269. self.assertEqual(conflicts, [])
  270. # Check that the merge commit has 4 parents (master + 3 branches)
  271. with Repo(tmpdir) as repo:
  272. commit = repo[merge_commit]
  273. self.assertEqual(len(commit.parents), 4)
  274. # Check that all modifications are present
  275. with open(os.path.join(tmpdir, "file1.txt")) as f:
  276. self.assertEqual(f.read(), "Branch1 modified file1\n")
  277. with open(os.path.join(tmpdir, "file2.txt")) as f:
  278. self.assertEqual(f.read(), "Branch2 modified file2\n")
  279. with open(os.path.join(tmpdir, "file3.txt")) as f:
  280. self.assertEqual(f.read(), "Branch3 modified file3\n")
  281. def test_octopus_merge_with_conflicts(self):
  282. # Check if merge3 module is available
  283. if importlib.util.find_spec("merge3") is None:
  284. raise DependencyMissing("merge3")
  285. """Test that octopus merge refuses to proceed with conflicts."""
  286. with tempfile.TemporaryDirectory() as tmpdir:
  287. # Initialize repo
  288. porcelain.init(tmpdir)
  289. # Create initial commit
  290. with open(os.path.join(tmpdir, "file1.txt"), "w") as f:
  291. f.write("Initial content\n")
  292. porcelain.add(tmpdir, paths=["file1.txt"])
  293. porcelain.commit(tmpdir, message=b"Initial commit")
  294. # Create branch1 and modify file1
  295. porcelain.branch_create(tmpdir, "branch1")
  296. porcelain.checkout(tmpdir, "branch1")
  297. with open(os.path.join(tmpdir, "file1.txt"), "w") as f:
  298. f.write("Branch1 content\n")
  299. porcelain.add(tmpdir, paths=["file1.txt"])
  300. porcelain.commit(tmpdir, message=b"Branch1 modifies file1")
  301. # Create branch2 and modify file1 differently
  302. porcelain.checkout(tmpdir, "master")
  303. porcelain.branch_create(tmpdir, "branch2")
  304. porcelain.checkout(tmpdir, "branch2")
  305. with open(os.path.join(tmpdir, "file1.txt"), "w") as f:
  306. f.write("Branch2 content\n")
  307. porcelain.add(tmpdir, paths=["file1.txt"])
  308. porcelain.commit(tmpdir, message=b"Branch2 modifies file1")
  309. # Go back to master and try octopus merge - should fail
  310. porcelain.checkout(tmpdir, "master")
  311. merge_commit, conflicts = porcelain.merge(tmpdir, ["branch1", "branch2"])
  312. # Should have conflicts and no merge commit
  313. self.assertIsNone(merge_commit)
  314. self.assertEqual(len(conflicts), 1)
  315. self.assertEqual(conflicts[0], b"file1.txt")
  316. def test_octopus_merge_no_commit_flag(self):
  317. """Test octopus merge with no_commit flag."""
  318. with tempfile.TemporaryDirectory() as tmpdir:
  319. # Initialize repo
  320. porcelain.init(tmpdir)
  321. # Create initial commit
  322. with open(os.path.join(tmpdir, "file1.txt"), "w") as f:
  323. f.write("File 1 content\n")
  324. with open(os.path.join(tmpdir, "file2.txt"), "w") as f:
  325. f.write("File 2 content\n")
  326. porcelain.add(tmpdir, paths=["file1.txt", "file2.txt"])
  327. master_commit = porcelain.commit(tmpdir, message=b"Initial commit")
  328. # Create branch1 and modify file1
  329. porcelain.branch_create(tmpdir, "branch1")
  330. porcelain.checkout(tmpdir, "branch1")
  331. with open(os.path.join(tmpdir, "file1.txt"), "w") as f:
  332. f.write("Branch1 modified file1\n")
  333. porcelain.add(tmpdir, paths=["file1.txt"])
  334. porcelain.commit(tmpdir, message=b"Branch1 modifies file1")
  335. # Create branch2 and modify file2
  336. porcelain.checkout(tmpdir, "master")
  337. porcelain.branch_create(tmpdir, "branch2")
  338. porcelain.checkout(tmpdir, "branch2")
  339. with open(os.path.join(tmpdir, "file2.txt"), "w") as f:
  340. f.write("Branch2 modified file2\n")
  341. porcelain.add(tmpdir, paths=["file2.txt"])
  342. porcelain.commit(tmpdir, message=b"Branch2 modifies file2")
  343. # Go back to master and octopus merge with no_commit
  344. porcelain.checkout(tmpdir, "master")
  345. merge_commit, conflicts = porcelain.merge(
  346. tmpdir, ["branch1", "branch2"], no_commit=True
  347. )
  348. # Should not create commit
  349. self.assertIsNone(merge_commit)
  350. self.assertEqual(conflicts, [])
  351. # Check that files are merged but no commit was created
  352. with open(os.path.join(tmpdir, "file1.txt")) as f:
  353. self.assertEqual(f.read(), "Branch1 modified file1\n")
  354. with open(os.path.join(tmpdir, "file2.txt")) as f:
  355. self.assertEqual(f.read(), "Branch2 modified file2\n")
  356. # HEAD should still point to master_commit
  357. with Repo(tmpdir) as repo:
  358. self.assertEqual(repo.refs[b"HEAD"], master_commit)
  359. def test_octopus_merge_single_branch(self):
  360. """Test that octopus merge with single branch falls back to regular merge."""
  361. with tempfile.TemporaryDirectory() as tmpdir:
  362. # Initialize repo
  363. porcelain.init(tmpdir)
  364. # Create initial commit
  365. with open(os.path.join(tmpdir, "file1.txt"), "w") as f:
  366. f.write("Initial content\n")
  367. with open(os.path.join(tmpdir, "file2.txt"), "w") as f:
  368. f.write("Initial file2\n")
  369. porcelain.add(tmpdir, paths=["file1.txt", "file2.txt"])
  370. porcelain.commit(tmpdir, message=b"Initial commit")
  371. # Create branch and modify file1
  372. porcelain.branch_create(tmpdir, "branch1")
  373. porcelain.checkout(tmpdir, "branch1")
  374. with open(os.path.join(tmpdir, "file1.txt"), "w") as f:
  375. f.write("Branch1 content\n")
  376. porcelain.add(tmpdir, paths=["file1.txt"])
  377. porcelain.commit(tmpdir, message=b"Branch1 changes")
  378. # Go back to master and modify file2 to prevent fast-forward
  379. porcelain.checkout(tmpdir, "master")
  380. with open(os.path.join(tmpdir, "file2.txt"), "w") as f:
  381. f.write("Master file2\n")
  382. porcelain.add(tmpdir, paths=["file2.txt"])
  383. porcelain.commit(tmpdir, message=b"Master changes")
  384. # Merge with list containing one branch
  385. merge_commit, conflicts = porcelain.merge(tmpdir, ["branch1"])
  386. # Should create a regular merge commit
  387. self.assertIsNotNone(merge_commit)
  388. self.assertEqual(conflicts, [])
  389. # Check the merge commit (should have 2 parents for regular merge)
  390. with Repo(tmpdir) as repo:
  391. commit = repo[merge_commit]
  392. self.assertEqual(len(commit.parents), 2)
  393. class PorcelainMergeTreeTests(TestCase):
  394. """Tests for the porcelain merge_tree functionality."""
  395. def test_merge_tree_no_conflicts(self):
  396. """Test merge_tree with no conflicts."""
  397. with tempfile.TemporaryDirectory() as tmpdir:
  398. # Initialize repo
  399. porcelain.init(tmpdir)
  400. repo = Repo(tmpdir)
  401. self.addCleanup(repo.close)
  402. # Create base tree
  403. with open(os.path.join(tmpdir, "file1.txt"), "w") as f:
  404. f.write("Base content\n")
  405. porcelain.add(tmpdir, paths=["file1.txt"])
  406. base_commit = porcelain.commit(tmpdir, message=b"Base commit")
  407. # Create our branch
  408. porcelain.branch_create(tmpdir, "ours")
  409. porcelain.checkout(tmpdir, "ours")
  410. with open(os.path.join(tmpdir, "file1.txt"), "w") as f:
  411. f.write("Our content\n")
  412. with open(os.path.join(tmpdir, "file2.txt"), "w") as f:
  413. f.write("Our new file\n")
  414. porcelain.add(tmpdir, paths=["file1.txt", "file2.txt"])
  415. our_commit = porcelain.commit(tmpdir, message=b"Our commit")
  416. # Create their branch
  417. porcelain.checkout(tmpdir, b"master")
  418. porcelain.branch_create(tmpdir, "theirs")
  419. porcelain.checkout(tmpdir, "theirs")
  420. with open(os.path.join(tmpdir, "file3.txt"), "w") as f:
  421. f.write("Their new file\n")
  422. porcelain.add(tmpdir, paths=["file3.txt"])
  423. their_commit = porcelain.commit(tmpdir, message=b"Their commit")
  424. # Perform merge_tree
  425. merged_tree_id, conflicts = porcelain.merge_tree(
  426. tmpdir, base_commit, our_commit, their_commit
  427. )
  428. # Should have no conflicts
  429. self.assertEqual(conflicts, [])
  430. # Check merged tree contains all files
  431. merged_tree = repo[merged_tree_id]
  432. self.assertIn(b"file1.txt", merged_tree)
  433. self.assertIn(b"file2.txt", merged_tree)
  434. self.assertIn(b"file3.txt", merged_tree)
  435. def test_merge_tree_with_conflicts(self):
  436. # Check if merge3 module is available
  437. if importlib.util.find_spec("merge3") is None:
  438. raise DependencyMissing("merge3")
  439. """Test merge_tree with conflicts."""
  440. with tempfile.TemporaryDirectory() as tmpdir:
  441. # Initialize repo
  442. porcelain.init(tmpdir)
  443. repo = Repo(tmpdir)
  444. self.addCleanup(repo.close)
  445. # Create base tree
  446. with open(os.path.join(tmpdir, "file1.txt"), "w") as f:
  447. f.write("Base content\n")
  448. porcelain.add(tmpdir, paths=["file1.txt"])
  449. base_commit = porcelain.commit(tmpdir, message=b"Base commit")
  450. # Create our branch with changes
  451. porcelain.branch_create(tmpdir, "ours")
  452. porcelain.checkout(tmpdir, "ours")
  453. with open(os.path.join(tmpdir, "file1.txt"), "w") as f:
  454. f.write("Our content\n")
  455. porcelain.add(tmpdir, paths=["file1.txt"])
  456. our_commit = porcelain.commit(tmpdir, message=b"Our commit")
  457. # Create their branch with conflicting changes
  458. porcelain.checkout(tmpdir, b"master")
  459. porcelain.branch_create(tmpdir, "theirs")
  460. porcelain.checkout(tmpdir, "theirs")
  461. with open(os.path.join(tmpdir, "file1.txt"), "w") as f:
  462. f.write("Their content\n")
  463. porcelain.add(tmpdir, paths=["file1.txt"])
  464. their_commit = porcelain.commit(tmpdir, message=b"Their commit")
  465. # Perform merge_tree
  466. merged_tree_id, conflicts = porcelain.merge_tree(
  467. tmpdir, base_commit, our_commit, their_commit
  468. )
  469. # Should have conflicts
  470. self.assertEqual(conflicts, [b"file1.txt"])
  471. # Check merged tree exists and contains conflict markers
  472. merged_tree = repo[merged_tree_id]
  473. self.assertIn(b"file1.txt", merged_tree)
  474. # Get the merged blob content
  475. _file_mode, file_sha = merged_tree[b"file1.txt"]
  476. merged_blob = repo[file_sha]
  477. content = merged_blob.data
  478. # Should contain conflict markers
  479. self.assertIn(b"<<<<<<< ours", content)
  480. self.assertIn(b"=======", content)
  481. self.assertIn(b">>>>>>> theirs", content)
  482. def test_merge_tree_no_base(self):
  483. """Test merge_tree without a base commit."""
  484. with tempfile.TemporaryDirectory() as tmpdir:
  485. # Initialize repo
  486. porcelain.init(tmpdir)
  487. repo = Repo(tmpdir)
  488. self.addCleanup(repo.close)
  489. # Create our tree
  490. with open(os.path.join(tmpdir, "file1.txt"), "w") as f:
  491. f.write("Our content\n")
  492. porcelain.add(tmpdir, paths=["file1.txt"])
  493. our_commit = porcelain.commit(tmpdir, message=b"Our commit")
  494. # Create their tree (independent)
  495. os.remove(os.path.join(tmpdir, "file1.txt"))
  496. with open(os.path.join(tmpdir, "file2.txt"), "w") as f:
  497. f.write("Their content\n")
  498. porcelain.add(tmpdir, paths=["file2.txt"])
  499. their_commit = porcelain.commit(tmpdir, message=b"Their commit")
  500. # Perform merge_tree without base
  501. merged_tree_id, conflicts = porcelain.merge_tree(
  502. tmpdir, None, our_commit, their_commit
  503. )
  504. # Should have no conflicts (different files)
  505. self.assertEqual(conflicts, [])
  506. # Check merged tree contains both files
  507. merged_tree = repo[merged_tree_id]
  508. self.assertIn(b"file1.txt", merged_tree)
  509. self.assertIn(b"file2.txt", merged_tree)
  510. def test_merge_tree_with_tree_objects(self):
  511. # Check if merge3 module is available
  512. if importlib.util.find_spec("merge3") is None:
  513. raise DependencyMissing("merge3")
  514. """Test merge_tree with tree objects instead of commits."""
  515. with tempfile.TemporaryDirectory() as tmpdir:
  516. # Initialize repo
  517. porcelain.init(tmpdir)
  518. repo = Repo(tmpdir)
  519. self.addCleanup(repo.close)
  520. # Create base tree
  521. with open(os.path.join(tmpdir, "file1.txt"), "w") as f:
  522. f.write("Base content\n")
  523. porcelain.add(tmpdir, paths=["file1.txt"])
  524. base_commit_id = porcelain.commit(tmpdir, message=b"Base commit")
  525. base_tree_id = repo[base_commit_id].tree
  526. # Create our tree
  527. with open(os.path.join(tmpdir, "file1.txt"), "w") as f:
  528. f.write("Our content\n")
  529. porcelain.add(tmpdir, paths=["file1.txt"])
  530. our_commit_id = porcelain.commit(tmpdir, message=b"Our commit")
  531. our_tree_id = repo[our_commit_id].tree
  532. # Create their tree
  533. with open(os.path.join(tmpdir, "file1.txt"), "w") as f:
  534. f.write("Their content\n")
  535. porcelain.add(tmpdir, paths=["file1.txt"])
  536. their_commit_id = porcelain.commit(tmpdir, message=b"Their commit")
  537. their_tree_id = repo[their_commit_id].tree
  538. # Perform merge_tree with tree SHAs
  539. _merged_tree_id, conflicts = porcelain.merge_tree(
  540. tmpdir,
  541. base_tree_id if base_tree_id else None,
  542. our_tree_id,
  543. their_tree_id,
  544. )
  545. # Should have conflicts
  546. self.assertEqual(conflicts, [b"file1.txt"])
  547. def test_merge_tree_invalid_object(self):
  548. """Test merge_tree with invalid object reference."""
  549. with tempfile.TemporaryDirectory() as tmpdir:
  550. # Initialize repo
  551. porcelain.init(tmpdir)
  552. # Create a commit
  553. with open(os.path.join(tmpdir, "file1.txt"), "w") as f:
  554. f.write("Content\n")
  555. porcelain.add(tmpdir, paths=["file1.txt"])
  556. commit_id = porcelain.commit(tmpdir, message=b"Commit")
  557. # Try to merge with nonexistent object
  558. self.assertRaises(
  559. KeyError,
  560. porcelain.merge_tree,
  561. tmpdir,
  562. None,
  563. commit_id,
  564. "0" * 40, # Invalid SHA
  565. )
  566. if __name__ == "__main__":
  567. unittest.main()