test_porcelain_merge.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281
  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 dulwich.tests 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(
  119. tmpdir, message=b"Modify file1 in feature"
  120. )
  121. # Go back to master and modify file2
  122. porcelain.checkout_branch(tmpdir, "master")
  123. with open(os.path.join(tmpdir, "file2.txt"), "w") as f:
  124. f.write("Master file2\n")
  125. porcelain.add(tmpdir, paths=["file2.txt"])
  126. porcelain.commit(tmpdir, message=b"Modify file2 in master")
  127. # Merge feature branch
  128. merge_commit, conflicts = porcelain.merge(tmpdir, "feature")
  129. self.assertIsNotNone(merge_commit)
  130. self.assertEqual(conflicts, [])
  131. # Check both modifications are present
  132. with open(os.path.join(tmpdir, "file1.txt")) as f:
  133. self.assertEqual(f.read(), "Feature content\n")
  134. with open(os.path.join(tmpdir, "file2.txt")) as f:
  135. self.assertEqual(f.read(), "Master file2\n")
  136. def test_merge_with_conflicts(self):
  137. """Test merge with conflicts."""
  138. with tempfile.TemporaryDirectory() as tmpdir:
  139. # Initialize repo
  140. porcelain.init(tmpdir)
  141. # Create initial commit
  142. with open(os.path.join(tmpdir, "file1.txt"), "w") as f:
  143. f.write("Initial content\n")
  144. porcelain.add(tmpdir, paths=["file1.txt"])
  145. porcelain.commit(tmpdir, message=b"Initial commit")
  146. # Create a branch and modify file1
  147. porcelain.branch_create(tmpdir, "feature")
  148. porcelain.checkout_branch(tmpdir, "feature")
  149. with open(os.path.join(tmpdir, "file1.txt"), "w") as f:
  150. f.write("Feature content\n")
  151. porcelain.add(tmpdir, paths=["file1.txt"])
  152. porcelain.commit(
  153. tmpdir, message=b"Modify file1 in feature"
  154. )
  155. # Go back to master and modify file1 differently
  156. porcelain.checkout_branch(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_branch(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_branch(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. if __name__ == "__main__":
  226. unittest.main()