test_porcelain_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()