test_worktree.py 26 KB

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