test_worktree.py 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721
  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 shutil
  24. import stat
  25. import tempfile
  26. from unittest import skipIf
  27. from dulwich import porcelain
  28. from dulwich.errors import CommitError
  29. from dulwich.object_store import tree_lookup_path
  30. from dulwich.repo import Repo
  31. from dulwich.worktree import (
  32. WorkTree,
  33. add_worktree,
  34. list_worktrees,
  35. lock_worktree,
  36. move_worktree,
  37. prune_worktrees,
  38. remove_worktree,
  39. unlock_worktree,
  40. )
  41. from . import TestCase
  42. class WorkTreeTestCase(TestCase):
  43. """Base test case for WorkTree tests."""
  44. def setUp(self):
  45. super().setUp()
  46. self.tempdir = tempfile.mkdtemp()
  47. self.test_dir = os.path.join(self.tempdir, "main")
  48. self.repo = Repo.init(self.test_dir, mkdir=True)
  49. # Create initial commit with a file
  50. with open(os.path.join(self.test_dir, "a"), "wb") as f:
  51. f.write(b"contents of file a")
  52. self.repo.get_worktree().stage(["a"])
  53. self.root_commit = self.repo.get_worktree().commit(
  54. message=b"Initial commit",
  55. committer=b"Test Committer <test@nodomain.com>",
  56. author=b"Test Author <test@nodomain.com>",
  57. commit_timestamp=12345,
  58. commit_timezone=0,
  59. author_timestamp=12345,
  60. author_timezone=0,
  61. )
  62. self.worktree = self.repo.get_worktree()
  63. def tearDown(self):
  64. self.repo.close()
  65. super().tearDown()
  66. def write_file(self, filename, content):
  67. """Helper to write a file in the repo."""
  68. with open(os.path.join(self.test_dir, filename), "wb") as f:
  69. f.write(content)
  70. class WorkTreeInitTests(TestCase):
  71. """Tests for WorkTree initialization."""
  72. def test_init_with_repo_path(self):
  73. """Test WorkTree initialization with same path as repo."""
  74. with tempfile.TemporaryDirectory() as tmpdir:
  75. repo = Repo.init(tmpdir)
  76. worktree = WorkTree(repo, tmpdir)
  77. self.assertEqual(worktree.path, tmpdir)
  78. self.assertEqual(worktree._repo, repo)
  79. self.assertTrue(os.path.isabs(worktree.path))
  80. def test_init_with_different_path(self):
  81. """Test WorkTree initialization with different path from repo."""
  82. with tempfile.TemporaryDirectory() as tmpdir:
  83. repo_path = os.path.join(tmpdir, "repo")
  84. worktree_path = os.path.join(tmpdir, "worktree")
  85. os.makedirs(repo_path)
  86. os.makedirs(worktree_path)
  87. repo = Repo.init(repo_path)
  88. worktree = WorkTree(repo, worktree_path)
  89. self.assertNotEqual(worktree.path, repo.path)
  90. self.assertEqual(worktree.path, worktree_path)
  91. self.assertEqual(worktree._repo, repo)
  92. self.assertTrue(os.path.isabs(worktree.path))
  93. def test_init_with_bytes_path(self):
  94. """Test WorkTree initialization with bytes path."""
  95. with tempfile.TemporaryDirectory() as tmpdir:
  96. repo = Repo.init(tmpdir)
  97. worktree = WorkTree(repo, tmpdir.encode("utf-8"))
  98. self.assertEqual(worktree.path, tmpdir)
  99. self.assertIsInstance(worktree.path, str)
  100. class WorkTreeStagingTests(WorkTreeTestCase):
  101. """Tests for WorkTree staging operations."""
  102. def test_stage_absolute(self):
  103. """Test that staging with absolute paths raises ValueError."""
  104. r = self.repo
  105. os.remove(os.path.join(r.path, "a"))
  106. self.assertRaises(ValueError, self.worktree.stage, [os.path.join(r.path, "a")])
  107. def test_stage_deleted(self):
  108. """Test staging a deleted file."""
  109. r = self.repo
  110. os.remove(os.path.join(r.path, "a"))
  111. self.worktree.stage(["a"])
  112. self.worktree.stage(["a"]) # double-stage a deleted path
  113. self.assertEqual([], list(r.open_index()))
  114. def test_stage_directory(self):
  115. """Test staging a directory."""
  116. r = self.repo
  117. os.mkdir(os.path.join(r.path, "c"))
  118. self.worktree.stage(["c"])
  119. self.assertEqual([b"a"], list(r.open_index()))
  120. def test_stage_submodule(self):
  121. """Test staging a submodule."""
  122. r = self.repo
  123. s = Repo.init(os.path.join(r.path, "sub"), mkdir=True)
  124. s.get_worktree().commit(
  125. message=b"message",
  126. )
  127. self.worktree.stage(["sub"])
  128. self.assertEqual([b"a", b"sub"], list(r.open_index()))
  129. class WorkTreeUnstagingTests(WorkTreeTestCase):
  130. """Tests for WorkTree unstaging operations."""
  131. def test_unstage_modify_file_with_dir(self):
  132. """Test unstaging a modified file in a directory."""
  133. os.mkdir(os.path.join(self.repo.path, "new_dir"))
  134. full_path = os.path.join(self.repo.path, "new_dir", "foo")
  135. with open(full_path, "w") as f:
  136. f.write("hello")
  137. porcelain.add(self.repo, paths=[full_path])
  138. porcelain.commit(
  139. self.repo,
  140. message=b"unittest",
  141. committer=b"Jane <jane@example.com>",
  142. author=b"John <john@example.com>",
  143. )
  144. with open(full_path, "a") as f:
  145. f.write("something new")
  146. self.worktree.unstage(["new_dir/foo"])
  147. status = list(porcelain.status(self.repo))
  148. self.assertEqual(
  149. [{"add": [], "delete": [], "modify": []}, [b"new_dir/foo"], []], status
  150. )
  151. def test_unstage_while_no_commit(self):
  152. """Test unstaging when there are no commits."""
  153. file = "foo"
  154. full_path = os.path.join(self.repo.path, file)
  155. with open(full_path, "w") as f:
  156. f.write("hello")
  157. porcelain.add(self.repo, paths=[full_path])
  158. self.worktree.unstage([file])
  159. status = list(porcelain.status(self.repo))
  160. self.assertEqual([{"add": [], "delete": [], "modify": []}, [], ["foo"]], status)
  161. def test_unstage_add_file(self):
  162. """Test unstaging a newly added file."""
  163. file = "foo"
  164. full_path = os.path.join(self.repo.path, file)
  165. porcelain.commit(
  166. self.repo,
  167. message=b"unittest",
  168. committer=b"Jane <jane@example.com>",
  169. author=b"John <john@example.com>",
  170. )
  171. with open(full_path, "w") as f:
  172. f.write("hello")
  173. porcelain.add(self.repo, paths=[full_path])
  174. self.worktree.unstage([file])
  175. status = list(porcelain.status(self.repo))
  176. self.assertEqual([{"add": [], "delete": [], "modify": []}, [], ["foo"]], status)
  177. def test_unstage_modify_file(self):
  178. """Test unstaging a modified file."""
  179. file = "foo"
  180. full_path = os.path.join(self.repo.path, file)
  181. with open(full_path, "w") as f:
  182. f.write("hello")
  183. porcelain.add(self.repo, paths=[full_path])
  184. porcelain.commit(
  185. self.repo,
  186. message=b"unittest",
  187. committer=b"Jane <jane@example.com>",
  188. author=b"John <john@example.com>",
  189. )
  190. with open(full_path, "a") as f:
  191. f.write("broken")
  192. porcelain.add(self.repo, paths=[full_path])
  193. self.worktree.unstage([file])
  194. status = list(porcelain.status(self.repo))
  195. self.assertEqual(
  196. [{"add": [], "delete": [], "modify": []}, [b"foo"], []], status
  197. )
  198. def test_unstage_remove_file(self):
  199. """Test unstaging a removed file."""
  200. file = "foo"
  201. full_path = os.path.join(self.repo.path, file)
  202. with open(full_path, "w") as f:
  203. f.write("hello")
  204. porcelain.add(self.repo, paths=[full_path])
  205. porcelain.commit(
  206. self.repo,
  207. message=b"unittest",
  208. committer=b"Jane <jane@example.com>",
  209. author=b"John <john@example.com>",
  210. )
  211. os.remove(full_path)
  212. self.worktree.unstage([file])
  213. status = list(porcelain.status(self.repo))
  214. self.assertEqual(
  215. [{"add": [], "delete": [], "modify": []}, [b"foo"], []], status
  216. )
  217. class WorkTreeCommitTests(WorkTreeTestCase):
  218. """Tests for WorkTree commit operations."""
  219. def test_commit_modified(self):
  220. """Test committing a modified file."""
  221. r = self.repo
  222. with open(os.path.join(r.path, "a"), "wb") as f:
  223. f.write(b"new contents")
  224. self.worktree.stage(["a"])
  225. commit_sha = self.worktree.commit(
  226. b"modified a",
  227. committer=b"Test Committer <test@nodomain.com>",
  228. author=b"Test Author <test@nodomain.com>",
  229. commit_timestamp=12395,
  230. commit_timezone=0,
  231. author_timestamp=12395,
  232. author_timezone=0,
  233. )
  234. self.assertEqual([self.root_commit], r[commit_sha].parents)
  235. a_mode, a_id = tree_lookup_path(r.get_object, r[commit_sha].tree, b"a")
  236. self.assertEqual(stat.S_IFREG | 0o644, a_mode)
  237. self.assertEqual(b"new contents", r[a_id].data)
  238. @skipIf(not getattr(os, "symlink", None), "Requires symlink support")
  239. def test_commit_symlink(self):
  240. """Test committing a symlink."""
  241. r = self.repo
  242. os.symlink("a", os.path.join(r.path, "b"))
  243. self.worktree.stage(["a", "b"])
  244. commit_sha = self.worktree.commit(
  245. b"Symlink b",
  246. committer=b"Test Committer <test@nodomain.com>",
  247. author=b"Test Author <test@nodomain.com>",
  248. commit_timestamp=12395,
  249. commit_timezone=0,
  250. author_timestamp=12395,
  251. author_timezone=0,
  252. )
  253. self.assertEqual([self.root_commit], r[commit_sha].parents)
  254. b_mode, b_id = tree_lookup_path(r.get_object, r[commit_sha].tree, b"b")
  255. self.assertEqual(stat.S_IFLNK, b_mode)
  256. self.assertEqual(b"a", r[b_id].data)
  257. class WorkTreeResetTests(WorkTreeTestCase):
  258. """Tests for WorkTree reset operations."""
  259. def test_reset_index(self):
  260. """Test resetting the index."""
  261. # Make some changes and stage them
  262. with open(os.path.join(self.repo.path, "a"), "wb") as f:
  263. f.write(b"modified contents")
  264. self.worktree.stage(["a"])
  265. # Reset index should restore to HEAD
  266. self.worktree.reset_index()
  267. # Check that the working tree file was restored
  268. with open(os.path.join(self.repo.path, "a"), "rb") as f:
  269. contents = f.read()
  270. self.assertEqual(b"contents of file a", contents)
  271. class WorkTreeSparseCheckoutTests(WorkTreeTestCase):
  272. """Tests for WorkTree sparse checkout operations."""
  273. def test_get_sparse_checkout_patterns_empty(self):
  274. """Test getting sparse checkout patterns when file doesn't exist."""
  275. patterns = self.worktree.get_sparse_checkout_patterns()
  276. self.assertEqual([], patterns)
  277. def test_set_sparse_checkout_patterns(self):
  278. """Test setting sparse checkout patterns."""
  279. patterns = ["*.py", "docs/"]
  280. self.worktree.set_sparse_checkout_patterns(patterns)
  281. # Read back the patterns
  282. retrieved_patterns = self.worktree.get_sparse_checkout_patterns()
  283. self.assertEqual(patterns, retrieved_patterns)
  284. def test_configure_for_cone_mode(self):
  285. """Test configuring repository for cone mode."""
  286. self.worktree.configure_for_cone_mode()
  287. config = self.repo.get_config()
  288. self.assertEqual(b"true", config.get((b"core",), b"sparseCheckout"))
  289. self.assertEqual(b"true", config.get((b"core",), b"sparseCheckoutCone"))
  290. def test_infer_cone_mode_false(self):
  291. """Test inferring cone mode when not configured."""
  292. self.assertFalse(self.worktree.infer_cone_mode())
  293. def test_infer_cone_mode_true(self):
  294. """Test inferring cone mode when configured."""
  295. self.worktree.configure_for_cone_mode()
  296. self.assertTrue(self.worktree.infer_cone_mode())
  297. def test_set_cone_mode_patterns(self):
  298. """Test setting cone mode patterns."""
  299. dirs = ["src", "tests"]
  300. self.worktree.set_cone_mode_patterns(dirs)
  301. patterns = self.worktree.get_sparse_checkout_patterns()
  302. expected = ["/*", "!/*/", "/src/", "/tests/"]
  303. self.assertEqual(expected, patterns)
  304. def test_set_cone_mode_patterns_empty(self):
  305. """Test setting cone mode patterns with empty list."""
  306. self.worktree.set_cone_mode_patterns([])
  307. patterns = self.worktree.get_sparse_checkout_patterns()
  308. expected = ["/*", "!/*/"]
  309. self.assertEqual(expected, patterns)
  310. def test_set_cone_mode_patterns_duplicates(self):
  311. """Test that duplicate patterns are not added."""
  312. dirs = ["src", "src"] # duplicate
  313. self.worktree.set_cone_mode_patterns(dirs)
  314. patterns = self.worktree.get_sparse_checkout_patterns()
  315. expected = ["/*", "!/*/", "/src/"]
  316. self.assertEqual(expected, patterns)
  317. def test_sparse_checkout_file_path(self):
  318. """Test getting the sparse checkout file path."""
  319. expected_path = os.path.join(self.repo.controldir(), "info", "sparse-checkout")
  320. actual_path = self.worktree._sparse_checkout_file_path()
  321. self.assertEqual(expected_path, actual_path)
  322. class WorkTreeBackwardCompatibilityTests(WorkTreeTestCase):
  323. """Tests for backward compatibility of deprecated Repo methods."""
  324. def test_deprecated_stage_delegates_to_worktree(self):
  325. """Test that deprecated Repo.stage delegates to WorkTree."""
  326. with open(os.path.join(self.repo.path, "new_file"), "w") as f:
  327. f.write("test content")
  328. # This should show a deprecation warning but still work
  329. import warnings
  330. with warnings.catch_warnings(record=True) as w:
  331. warnings.simplefilter("always")
  332. self.repo.stage(
  333. ["new_file"]
  334. ) # Call deprecated method on Repo, not WorkTree
  335. self.assertTrue(len(w) > 0)
  336. self.assertTrue(issubclass(w[0].category, DeprecationWarning))
  337. def test_deprecated_unstage_delegates_to_worktree(self):
  338. """Test that deprecated Repo.unstage delegates to WorkTree."""
  339. # This should show a deprecation warning but still work
  340. import warnings
  341. with warnings.catch_warnings(record=True) as w:
  342. warnings.simplefilter("always")
  343. self.repo.unstage(["a"]) # Call deprecated method on Repo, not WorkTree
  344. self.assertTrue(len(w) > 0)
  345. self.assertTrue(issubclass(w[0].category, DeprecationWarning))
  346. def test_deprecated_sparse_checkout_methods(self):
  347. """Test that deprecated sparse checkout methods delegate to WorkTree."""
  348. import warnings
  349. # Test get_sparse_checkout_patterns
  350. with warnings.catch_warnings(record=True) as w:
  351. warnings.simplefilter("always")
  352. patterns = (
  353. self.repo.get_sparse_checkout_patterns()
  354. ) # Call deprecated method on Repo
  355. self.assertEqual([], patterns)
  356. self.assertTrue(len(w) > 0)
  357. self.assertTrue(issubclass(w[0].category, DeprecationWarning))
  358. # Test set_sparse_checkout_patterns
  359. with warnings.catch_warnings(record=True) as w:
  360. warnings.simplefilter("always")
  361. self.repo.set_sparse_checkout_patterns(
  362. ["*.py"]
  363. ) # Call deprecated method on Repo
  364. self.assertTrue(len(w) > 0)
  365. self.assertTrue(issubclass(w[0].category, DeprecationWarning))
  366. def test_pre_commit_hook_fail(self):
  367. """Test that failing pre-commit hook raises CommitError."""
  368. if os.name != "posix":
  369. self.skipTest("shell hook tests requires POSIX shell")
  370. # Create a failing pre-commit hook
  371. hooks_dir = os.path.join(self.repo.controldir(), "hooks")
  372. os.makedirs(hooks_dir, exist_ok=True)
  373. hook_path = os.path.join(hooks_dir, "pre-commit")
  374. with open(hook_path, "w") as f:
  375. f.write("#!/bin/sh\nexit 1\n")
  376. os.chmod(hook_path, 0o755)
  377. # Try to commit
  378. worktree = self.repo.get_worktree()
  379. with self.assertRaises(CommitError):
  380. worktree.commit(b"No message")
  381. def write_file(self, filename, content):
  382. """Helper to write a file in the repo."""
  383. with open(os.path.join(self.test_dir, filename), "wb") as f:
  384. f.write(content)
  385. class WorkTreeOperationsTests(WorkTreeTestCase):
  386. """Tests for worktree operations like add, list, remove."""
  387. def test_list_worktrees_single(self) -> None:
  388. """Test listing worktrees when only main worktree exists."""
  389. worktrees = list_worktrees(self.repo)
  390. self.assertEqual(len(worktrees), 1)
  391. self.assertEqual(worktrees[0].path, self.repo.path)
  392. self.assertEqual(worktrees[0].bare, False)
  393. self.assertIsNotNone(worktrees[0].head)
  394. self.assertIsNotNone(worktrees[0].branch)
  395. def test_add_worktree_new_branch(self) -> None:
  396. """Test adding a worktree with a new branch."""
  397. # Create a commit first
  398. worktree = self.repo.get_worktree()
  399. self.write_file("test.txt", b"test content")
  400. worktree.stage(["test.txt"])
  401. commit_id = worktree.commit(message=b"Initial commit")
  402. # Add a new worktree
  403. wt_path = os.path.join(self.tempdir, "new-worktree")
  404. add_worktree(self.repo, wt_path, branch=b"feature-branch")
  405. # Verify worktree was created
  406. self.assertTrue(os.path.exists(wt_path))
  407. self.assertTrue(os.path.exists(os.path.join(wt_path, ".git")))
  408. # Verify it appears in the list
  409. worktrees = list_worktrees(self.repo)
  410. self.assertEqual(len(worktrees), 2)
  411. # Find the new worktree in the list
  412. new_wt = None
  413. for wt in worktrees:
  414. if wt.path == wt_path:
  415. new_wt = wt
  416. break
  417. self.assertIsNotNone(new_wt)
  418. self.assertEqual(new_wt.branch, b"refs/heads/feature-branch")
  419. self.assertEqual(new_wt.head, commit_id)
  420. self.assertFalse(new_wt.detached)
  421. def test_add_worktree_detached(self) -> None:
  422. """Test adding a worktree with detached HEAD."""
  423. # Create a commit
  424. worktree = self.repo.get_worktree()
  425. self.write_file("test.txt", b"test content")
  426. worktree.stage(["test.txt"])
  427. commit_id = worktree.commit(message=b"Initial commit")
  428. # Add a detached worktree
  429. wt_path = os.path.join(self.tempdir, "detached-worktree")
  430. add_worktree(self.repo, wt_path, commit=commit_id, detach=True)
  431. # Verify it's detached
  432. worktrees = list_worktrees(self.repo)
  433. self.assertEqual(len(worktrees), 2)
  434. for wt in worktrees:
  435. if wt.path == wt_path:
  436. self.assertTrue(wt.detached)
  437. self.assertIsNone(wt.branch)
  438. self.assertEqual(wt.head, commit_id)
  439. def test_add_worktree_existing_path(self) -> None:
  440. """Test that adding a worktree to existing path fails."""
  441. wt_path = os.path.join(self.tempdir, "existing")
  442. os.mkdir(wt_path)
  443. with self.assertRaises(ValueError) as cm:
  444. add_worktree(self.repo, wt_path)
  445. self.assertIn("Path already exists", str(cm.exception))
  446. def test_add_worktree_branch_already_checked_out(self) -> None:
  447. """Test that checking out same branch in multiple worktrees fails."""
  448. # Create initial commit
  449. worktree = self.repo.get_worktree()
  450. self.write_file("test.txt", b"test content")
  451. worktree.stage(["test.txt"])
  452. worktree.commit(message=b"Initial commit")
  453. # First worktree should succeed with a new branch
  454. wt_path1 = os.path.join(self.tempdir, "wt1")
  455. add_worktree(self.repo, wt_path1, branch=b"feature")
  456. # Second worktree with same branch should fail
  457. wt_path2 = os.path.join(self.tempdir, "wt2")
  458. with self.assertRaises(ValueError) as cm:
  459. add_worktree(self.repo, wt_path2, branch=b"feature")
  460. self.assertIn("already checked out", str(cm.exception))
  461. # But should work with force=True
  462. add_worktree(self.repo, wt_path2, branch=b"feature", force=True)
  463. def test_remove_worktree(self) -> None:
  464. """Test removing a worktree."""
  465. # Create a worktree
  466. wt_path = os.path.join(self.tempdir, "to-remove")
  467. add_worktree(self.repo, wt_path)
  468. # Verify it exists
  469. self.assertTrue(os.path.exists(wt_path))
  470. self.assertEqual(len(list_worktrees(self.repo)), 2)
  471. # Remove it
  472. remove_worktree(self.repo, wt_path)
  473. # Verify it's gone
  474. self.assertFalse(os.path.exists(wt_path))
  475. self.assertEqual(len(list_worktrees(self.repo)), 1)
  476. def test_remove_main_worktree_fails(self) -> None:
  477. """Test that removing the main worktree fails."""
  478. with self.assertRaises(ValueError) as cm:
  479. remove_worktree(self.repo, self.repo.path)
  480. self.assertIn("Cannot remove the main working tree", str(cm.exception))
  481. def test_remove_nonexistent_worktree(self) -> None:
  482. """Test that removing non-existent worktree fails."""
  483. with self.assertRaises(ValueError) as cm:
  484. remove_worktree(self.repo, "/nonexistent/path")
  485. self.assertIn("Worktree not found", str(cm.exception))
  486. def test_lock_unlock_worktree(self) -> None:
  487. """Test locking and unlocking a worktree."""
  488. # Create a worktree
  489. wt_path = os.path.join(self.tempdir, "lockable")
  490. add_worktree(self.repo, wt_path)
  491. # Lock it
  492. lock_worktree(self.repo, wt_path, reason="Testing lock")
  493. # Verify it's locked
  494. worktrees = list_worktrees(self.repo)
  495. for wt in worktrees:
  496. if wt.path == wt_path:
  497. self.assertTrue(wt.locked)
  498. # Try to remove locked worktree (should fail)
  499. with self.assertRaises(ValueError) as cm:
  500. remove_worktree(self.repo, wt_path)
  501. self.assertIn("locked", str(cm.exception))
  502. # Unlock it
  503. unlock_worktree(self.repo, wt_path)
  504. # Verify it's unlocked
  505. worktrees = list_worktrees(self.repo)
  506. for wt in worktrees:
  507. if wt.path == wt_path:
  508. self.assertFalse(wt.locked)
  509. # Now removal should work
  510. remove_worktree(self.repo, wt_path)
  511. def test_prune_worktrees(self) -> None:
  512. """Test pruning worktrees."""
  513. # Create a worktree
  514. wt_path = os.path.join(self.tempdir, "to-prune")
  515. add_worktree(self.repo, wt_path)
  516. # Manually remove the worktree directory
  517. shutil.rmtree(wt_path)
  518. # Verify it still shows up as prunable
  519. worktrees = list_worktrees(self.repo)
  520. prunable_count = sum(1 for wt in worktrees if wt.prunable)
  521. self.assertEqual(prunable_count, 1)
  522. # Prune it
  523. pruned = prune_worktrees(self.repo)
  524. self.assertEqual(len(pruned), 1)
  525. # Verify it's gone from the list
  526. worktrees = list_worktrees(self.repo)
  527. self.assertEqual(len(worktrees), 1)
  528. def test_prune_dry_run(self) -> None:
  529. """Test prune with dry_run doesn't remove anything."""
  530. # Create and manually remove a worktree
  531. wt_path = os.path.join(self.tempdir, "dry-run-test")
  532. add_worktree(self.repo, wt_path)
  533. shutil.rmtree(wt_path)
  534. # Dry run should report but not remove
  535. pruned = prune_worktrees(self.repo, dry_run=True)
  536. self.assertEqual(len(pruned), 1)
  537. # Worktree should still be in list
  538. worktrees = list_worktrees(self.repo)
  539. self.assertEqual(len(worktrees), 2)
  540. def test_prune_locked_worktree_not_pruned(self) -> None:
  541. """Test that locked worktrees are not pruned."""
  542. # Create and lock a worktree
  543. wt_path = os.path.join(self.tempdir, "locked-prune")
  544. add_worktree(self.repo, wt_path)
  545. lock_worktree(self.repo, wt_path)
  546. # Remove the directory
  547. shutil.rmtree(wt_path)
  548. # Prune should not remove locked worktree
  549. pruned = prune_worktrees(self.repo)
  550. self.assertEqual(len(pruned), 0)
  551. # Worktree should still be in list
  552. worktrees = list_worktrees(self.repo)
  553. self.assertEqual(len(worktrees), 2)
  554. def test_move_worktree(self) -> None:
  555. """Test moving a worktree."""
  556. # Create a worktree
  557. wt_path = os.path.join(self.tempdir, "to-move")
  558. add_worktree(self.repo, wt_path)
  559. # Create a file in the worktree
  560. test_file = os.path.join(wt_path, "test.txt")
  561. with open(test_file, "w") as f:
  562. f.write("test content")
  563. # Move it
  564. new_path = os.path.join(self.tempdir, "moved")
  565. move_worktree(self.repo, wt_path, new_path)
  566. # Verify old path doesn't exist
  567. self.assertFalse(os.path.exists(wt_path))
  568. # Verify new path exists with contents
  569. self.assertTrue(os.path.exists(new_path))
  570. self.assertTrue(os.path.exists(os.path.join(new_path, "test.txt")))
  571. # Verify it's in the list at new location
  572. worktrees = list_worktrees(self.repo)
  573. paths = [wt.path for wt in worktrees]
  574. self.assertIn(new_path, paths)
  575. self.assertNotIn(wt_path, paths)
  576. def test_move_main_worktree_fails(self) -> None:
  577. """Test that moving the main worktree fails."""
  578. new_path = os.path.join(self.tempdir, "new-main")
  579. with self.assertRaises(ValueError) as cm:
  580. move_worktree(self.repo, self.repo.path, new_path)
  581. self.assertIn("Cannot move the main working tree", str(cm.exception))
  582. def test_move_to_existing_path_fails(self) -> None:
  583. """Test that moving to an existing path fails."""
  584. # Create a worktree
  585. wt_path = os.path.join(self.tempdir, "worktree")
  586. add_worktree(self.repo, wt_path)
  587. # Create target directory
  588. new_path = os.path.join(self.tempdir, "existing")
  589. os.makedirs(new_path)
  590. with self.assertRaises(ValueError) as cm:
  591. move_worktree(self.repo, wt_path, new_path)
  592. self.assertIn("Path already exists", str(cm.exception))