2
0

test_worktree.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410
  1. # test_worktree.py -- Tests for dulwich.worktree
  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 dulwich.worktree."""
  22. import os
  23. import stat
  24. import tempfile
  25. from unittest import skipIf
  26. from dulwich import porcelain
  27. from dulwich.object_store import tree_lookup_path
  28. from dulwich.repo import Repo
  29. from dulwich.worktree import WorkTree
  30. from . import TestCase
  31. class WorkTreeTestCase(TestCase):
  32. """Base test case for WorkTree tests."""
  33. def setUp(self):
  34. super().setUp()
  35. self.test_dir = tempfile.mkdtemp()
  36. self.repo = Repo.init(self.test_dir)
  37. # Create initial commit with a file
  38. with open(os.path.join(self.test_dir, "a"), "wb") as f:
  39. f.write(b"contents of file a")
  40. self.repo.stage(["a"])
  41. self.root_commit = self.repo.do_commit(
  42. b"Initial commit",
  43. committer=b"Test Committer <test@nodomain.com>",
  44. author=b"Test Author <test@nodomain.com>",
  45. commit_timestamp=12345,
  46. commit_timezone=0,
  47. author_timestamp=12345,
  48. author_timezone=0,
  49. )
  50. self.worktree = self.repo.get_worktree()
  51. def tearDown(self):
  52. self.repo.close()
  53. super().tearDown()
  54. class WorkTreeInitTests(TestCase):
  55. """Tests for WorkTree initialization."""
  56. def test_init_with_repo_path(self):
  57. """Test WorkTree initialization with same path as repo."""
  58. with tempfile.TemporaryDirectory() as tmpdir:
  59. repo = Repo.init(tmpdir)
  60. worktree = WorkTree(repo, tmpdir)
  61. self.assertEqual(worktree.path, tmpdir)
  62. self.assertEqual(worktree._repo, repo)
  63. self.assertTrue(os.path.isabs(worktree.path))
  64. def test_init_with_different_path(self):
  65. """Test WorkTree initialization with different path from repo."""
  66. with tempfile.TemporaryDirectory() as tmpdir:
  67. repo_path = os.path.join(tmpdir, "repo")
  68. worktree_path = os.path.join(tmpdir, "worktree")
  69. os.makedirs(repo_path)
  70. os.makedirs(worktree_path)
  71. repo = Repo.init(repo_path)
  72. worktree = WorkTree(repo, worktree_path)
  73. self.assertNotEqual(worktree.path, repo.path)
  74. self.assertEqual(worktree.path, worktree_path)
  75. self.assertEqual(worktree._repo, repo)
  76. self.assertTrue(os.path.isabs(worktree.path))
  77. def test_init_with_bytes_path(self):
  78. """Test WorkTree initialization with bytes path."""
  79. with tempfile.TemporaryDirectory() as tmpdir:
  80. repo = Repo.init(tmpdir)
  81. worktree = WorkTree(repo, tmpdir.encode("utf-8"))
  82. self.assertEqual(worktree.path, tmpdir)
  83. self.assertIsInstance(worktree.path, str)
  84. class WorkTreeStagingTests(WorkTreeTestCase):
  85. """Tests for WorkTree staging operations."""
  86. def test_stage_absolute(self):
  87. """Test that staging with absolute paths raises ValueError."""
  88. r = self.repo
  89. os.remove(os.path.join(r.path, "a"))
  90. self.assertRaises(ValueError, self.worktree.stage, [os.path.join(r.path, "a")])
  91. def test_stage_deleted(self):
  92. """Test staging a deleted file."""
  93. r = self.repo
  94. os.remove(os.path.join(r.path, "a"))
  95. self.worktree.stage(["a"])
  96. self.worktree.stage(["a"]) # double-stage a deleted path
  97. self.assertEqual([], list(r.open_index()))
  98. def test_stage_directory(self):
  99. """Test staging a directory."""
  100. r = self.repo
  101. os.mkdir(os.path.join(r.path, "c"))
  102. self.worktree.stage(["c"])
  103. self.assertEqual([b"a"], list(r.open_index()))
  104. def test_stage_submodule(self):
  105. """Test staging a submodule."""
  106. r = self.repo
  107. s = Repo.init(os.path.join(r.path, "sub"), mkdir=True)
  108. s.do_commit(b"message")
  109. self.worktree.stage(["sub"])
  110. self.assertEqual([b"a", b"sub"], list(r.open_index()))
  111. class WorkTreeUnstagingTests(WorkTreeTestCase):
  112. """Tests for WorkTree unstaging operations."""
  113. def test_unstage_modify_file_with_dir(self):
  114. """Test unstaging a modified file in a directory."""
  115. os.mkdir(os.path.join(self.repo.path, "new_dir"))
  116. full_path = os.path.join(self.repo.path, "new_dir", "foo")
  117. with open(full_path, "w") as f:
  118. f.write("hello")
  119. porcelain.add(self.repo, paths=[full_path])
  120. porcelain.commit(
  121. self.repo,
  122. message=b"unittest",
  123. committer=b"Jane <jane@example.com>",
  124. author=b"John <john@example.com>",
  125. )
  126. with open(full_path, "a") as f:
  127. f.write("something new")
  128. self.worktree.unstage(["new_dir/foo"])
  129. status = list(porcelain.status(self.repo))
  130. self.assertEqual(
  131. [{"add": [], "delete": [], "modify": []}, [b"new_dir/foo"], []], status
  132. )
  133. def test_unstage_while_no_commit(self):
  134. """Test unstaging when there are no commits."""
  135. file = "foo"
  136. full_path = os.path.join(self.repo.path, file)
  137. with open(full_path, "w") as f:
  138. f.write("hello")
  139. porcelain.add(self.repo, paths=[full_path])
  140. self.worktree.unstage([file])
  141. status = list(porcelain.status(self.repo))
  142. self.assertEqual([{"add": [], "delete": [], "modify": []}, [], ["foo"]], status)
  143. def test_unstage_add_file(self):
  144. """Test unstaging a newly added file."""
  145. file = "foo"
  146. full_path = os.path.join(self.repo.path, file)
  147. porcelain.commit(
  148. self.repo,
  149. message=b"unittest",
  150. committer=b"Jane <jane@example.com>",
  151. author=b"John <john@example.com>",
  152. )
  153. with open(full_path, "w") as f:
  154. f.write("hello")
  155. porcelain.add(self.repo, paths=[full_path])
  156. self.worktree.unstage([file])
  157. status = list(porcelain.status(self.repo))
  158. self.assertEqual([{"add": [], "delete": [], "modify": []}, [], ["foo"]], status)
  159. def test_unstage_modify_file(self):
  160. """Test unstaging a modified file."""
  161. file = "foo"
  162. full_path = os.path.join(self.repo.path, file)
  163. with open(full_path, "w") as f:
  164. f.write("hello")
  165. porcelain.add(self.repo, paths=[full_path])
  166. porcelain.commit(
  167. self.repo,
  168. message=b"unittest",
  169. committer=b"Jane <jane@example.com>",
  170. author=b"John <john@example.com>",
  171. )
  172. with open(full_path, "a") as f:
  173. f.write("broken")
  174. porcelain.add(self.repo, paths=[full_path])
  175. self.worktree.unstage([file])
  176. status = list(porcelain.status(self.repo))
  177. self.assertEqual(
  178. [{"add": [], "delete": [], "modify": []}, [b"foo"], []], status
  179. )
  180. def test_unstage_remove_file(self):
  181. """Test unstaging a removed file."""
  182. file = "foo"
  183. full_path = os.path.join(self.repo.path, file)
  184. with open(full_path, "w") as f:
  185. f.write("hello")
  186. porcelain.add(self.repo, paths=[full_path])
  187. porcelain.commit(
  188. self.repo,
  189. message=b"unittest",
  190. committer=b"Jane <jane@example.com>",
  191. author=b"John <john@example.com>",
  192. )
  193. os.remove(full_path)
  194. self.worktree.unstage([file])
  195. status = list(porcelain.status(self.repo))
  196. self.assertEqual(
  197. [{"add": [], "delete": [], "modify": []}, [b"foo"], []], status
  198. )
  199. class WorkTreeCommitTests(WorkTreeTestCase):
  200. """Tests for WorkTree commit operations."""
  201. def test_commit_modified(self):
  202. """Test committing a modified file."""
  203. r = self.repo
  204. with open(os.path.join(r.path, "a"), "wb") as f:
  205. f.write(b"new contents")
  206. self.worktree.stage(["a"])
  207. commit_sha = self.worktree.commit(
  208. b"modified a",
  209. committer=b"Test Committer <test@nodomain.com>",
  210. author=b"Test Author <test@nodomain.com>",
  211. commit_timestamp=12395,
  212. commit_timezone=0,
  213. author_timestamp=12395,
  214. author_timezone=0,
  215. )
  216. self.assertEqual([self.root_commit], r[commit_sha].parents)
  217. a_mode, a_id = tree_lookup_path(r.get_object, r[commit_sha].tree, b"a")
  218. self.assertEqual(stat.S_IFREG | 0o644, a_mode)
  219. self.assertEqual(b"new contents", r[a_id].data)
  220. @skipIf(not getattr(os, "symlink", None), "Requires symlink support")
  221. def test_commit_symlink(self):
  222. """Test committing a symlink."""
  223. r = self.repo
  224. os.symlink("a", os.path.join(r.path, "b"))
  225. self.worktree.stage(["a", "b"])
  226. commit_sha = self.worktree.commit(
  227. b"Symlink b",
  228. committer=b"Test Committer <test@nodomain.com>",
  229. author=b"Test Author <test@nodomain.com>",
  230. commit_timestamp=12395,
  231. commit_timezone=0,
  232. author_timestamp=12395,
  233. author_timezone=0,
  234. )
  235. self.assertEqual([self.root_commit], r[commit_sha].parents)
  236. b_mode, b_id = tree_lookup_path(r.get_object, r[commit_sha].tree, b"b")
  237. self.assertEqual(stat.S_IFLNK, b_mode)
  238. self.assertEqual(b"a", r[b_id].data)
  239. class WorkTreeResetTests(WorkTreeTestCase):
  240. """Tests for WorkTree reset operations."""
  241. def test_reset_index(self):
  242. """Test resetting the index."""
  243. # Make some changes and stage them
  244. with open(os.path.join(self.repo.path, "a"), "wb") as f:
  245. f.write(b"modified contents")
  246. self.worktree.stage(["a"])
  247. # Reset index should restore to HEAD
  248. self.worktree.reset_index()
  249. # Check that the working tree file was restored
  250. with open(os.path.join(self.repo.path, "a"), "rb") as f:
  251. contents = f.read()
  252. self.assertEqual(b"contents of file a", contents)
  253. class WorkTreeSparseCheckoutTests(WorkTreeTestCase):
  254. """Tests for WorkTree sparse checkout operations."""
  255. def test_get_sparse_checkout_patterns_empty(self):
  256. """Test getting sparse checkout patterns when file doesn't exist."""
  257. patterns = self.worktree.get_sparse_checkout_patterns()
  258. self.assertEqual([], patterns)
  259. def test_set_sparse_checkout_patterns(self):
  260. """Test setting sparse checkout patterns."""
  261. patterns = ["*.py", "docs/"]
  262. self.worktree.set_sparse_checkout_patterns(patterns)
  263. # Read back the patterns
  264. retrieved_patterns = self.worktree.get_sparse_checkout_patterns()
  265. self.assertEqual(patterns, retrieved_patterns)
  266. def test_configure_for_cone_mode(self):
  267. """Test configuring repository for cone mode."""
  268. self.worktree.configure_for_cone_mode()
  269. config = self.repo.get_config()
  270. self.assertEqual(b"true", config.get((b"core",), b"sparseCheckout"))
  271. self.assertEqual(b"true", config.get((b"core",), b"sparseCheckoutCone"))
  272. def test_infer_cone_mode_false(self):
  273. """Test inferring cone mode when not configured."""
  274. self.assertFalse(self.worktree.infer_cone_mode())
  275. def test_infer_cone_mode_true(self):
  276. """Test inferring cone mode when configured."""
  277. self.worktree.configure_for_cone_mode()
  278. self.assertTrue(self.worktree.infer_cone_mode())
  279. def test_set_cone_mode_patterns(self):
  280. """Test setting cone mode patterns."""
  281. dirs = ["src", "tests"]
  282. self.worktree.set_cone_mode_patterns(dirs)
  283. patterns = self.worktree.get_sparse_checkout_patterns()
  284. expected = ["/*", "!/*/", "/src/", "/tests/"]
  285. self.assertEqual(expected, patterns)
  286. def test_set_cone_mode_patterns_empty(self):
  287. """Test setting cone mode patterns with empty list."""
  288. self.worktree.set_cone_mode_patterns([])
  289. patterns = self.worktree.get_sparse_checkout_patterns()
  290. expected = ["/*", "!/*/"]
  291. self.assertEqual(expected, patterns)
  292. def test_set_cone_mode_patterns_duplicates(self):
  293. """Test that duplicate patterns are not added."""
  294. dirs = ["src", "src"] # duplicate
  295. self.worktree.set_cone_mode_patterns(dirs)
  296. patterns = self.worktree.get_sparse_checkout_patterns()
  297. expected = ["/*", "!/*/", "/src/"]
  298. self.assertEqual(expected, patterns)
  299. def test_sparse_checkout_file_path(self):
  300. """Test getting the sparse checkout file path."""
  301. expected_path = os.path.join(self.repo.controldir(), "info", "sparse-checkout")
  302. actual_path = self.worktree._sparse_checkout_file_path()
  303. self.assertEqual(expected_path, actual_path)
  304. class WorkTreeBackwardCompatibilityTests(WorkTreeTestCase):
  305. """Tests for backward compatibility of deprecated Repo methods."""
  306. def test_deprecated_stage_delegates_to_worktree(self):
  307. """Test that deprecated Repo.stage delegates to WorkTree."""
  308. with open(os.path.join(self.repo.path, "new_file"), "w") as f:
  309. f.write("test content")
  310. # This should show a deprecation warning but still work
  311. import warnings
  312. with warnings.catch_warnings(record=True) as w:
  313. warnings.simplefilter("always")
  314. self.repo.stage(["new_file"])
  315. self.assertTrue(len(w) > 0)
  316. self.assertTrue(issubclass(w[0].category, DeprecationWarning))
  317. def test_deprecated_unstage_delegates_to_worktree(self):
  318. """Test that deprecated Repo.unstage delegates to WorkTree."""
  319. # This should show a deprecation warning but still work
  320. import warnings
  321. with warnings.catch_warnings(record=True) as w:
  322. warnings.simplefilter("always")
  323. self.repo.unstage(["a"])
  324. self.assertTrue(len(w) > 0)
  325. self.assertTrue(issubclass(w[0].category, DeprecationWarning))
  326. def test_deprecated_sparse_checkout_methods(self):
  327. """Test that deprecated sparse checkout methods delegate to WorkTree."""
  328. import warnings
  329. # Test get_sparse_checkout_patterns
  330. with warnings.catch_warnings(record=True) as w:
  331. warnings.simplefilter("always")
  332. patterns = self.repo.get_sparse_checkout_patterns()
  333. self.assertEqual([], patterns)
  334. self.assertTrue(len(w) > 0)
  335. self.assertTrue(issubclass(w[0].category, DeprecationWarning))
  336. # Test set_sparse_checkout_patterns
  337. with warnings.catch_warnings(record=True) as w:
  338. warnings.simplefilter("always")
  339. self.repo.set_sparse_checkout_patterns(["*.py"])
  340. self.assertTrue(len(w) > 0)
  341. self.assertTrue(issubclass(w[0].category, DeprecationWarning))