test_cli.py 107 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899
  1. #!/usr/bin/env python
  2. # test_cli.py -- tests for dulwich.cli
  3. # vim: expandtab
  4. #
  5. # Copyright (C) 2024 Jelmer Vernooij <jelmer@jelmer.uk>
  6. #
  7. # SPDX-License-Identifier: Apache-2.0 OR GPL-2.0-or-later
  8. # Dulwich is dual-licensed under the Apache License, Version 2.0 and the GNU
  9. # General Public License as published by the Free Software Foundation; version 2.0
  10. # or (at your option) any later version. You can redistribute it and/or
  11. # modify it under the terms of either of these two licenses.
  12. #
  13. # Unless required by applicable law or agreed to in writing, software
  14. # distributed under the License is distributed on an "AS IS" BASIS,
  15. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  16. # See the License for the specific language governing permissions and
  17. # limitations under the License.
  18. #
  19. # You should have received a copy of the licenses; if not, see
  20. # <http://www.gnu.org/licenses/> for a copy of the GNU General Public License
  21. # and <http://www.apache.org/licenses/LICENSE-2.0> for a copy of the Apache
  22. # License, Version 2.0.
  23. """Tests for dulwich.cli."""
  24. import io
  25. import os
  26. import shutil
  27. import sys
  28. import tempfile
  29. import unittest
  30. from unittest import skipIf
  31. from unittest.mock import MagicMock, patch
  32. from dulwich import cli
  33. from dulwich.cli import format_bytes, launch_editor, parse_relative_time, write_columns
  34. from dulwich.repo import Repo
  35. from dulwich.tests.utils import (
  36. build_commit_graph,
  37. )
  38. from . import TestCase
  39. class DulwichCliTestCase(TestCase):
  40. """Base class for CLI tests."""
  41. def setUp(self) -> None:
  42. super().setUp()
  43. self.test_dir = tempfile.mkdtemp()
  44. self.addCleanup(shutil.rmtree, self.test_dir)
  45. self.repo_path = os.path.join(self.test_dir, "repo")
  46. os.mkdir(self.repo_path)
  47. self.repo = Repo.init(self.repo_path)
  48. self.addCleanup(self.repo.close)
  49. def _run_cli(self, *args, stdout_stream=None):
  50. """Run CLI command and capture output."""
  51. class MockStream:
  52. def __init__(self):
  53. self._buffer = io.BytesIO()
  54. self.buffer = self._buffer
  55. def write(self, data):
  56. if isinstance(data, bytes):
  57. self._buffer.write(data)
  58. else:
  59. self._buffer.write(data.encode("utf-8"))
  60. def getvalue(self):
  61. value = self._buffer.getvalue()
  62. try:
  63. return value.decode("utf-8")
  64. except UnicodeDecodeError:
  65. return value
  66. def __getattr__(self, name):
  67. return getattr(self._buffer, name)
  68. old_stdout = sys.stdout
  69. old_stderr = sys.stderr
  70. old_cwd = os.getcwd()
  71. try:
  72. # Use custom stdout_stream if provided, otherwise use MockStream
  73. if stdout_stream:
  74. sys.stdout = stdout_stream
  75. if not hasattr(sys.stdout, "buffer"):
  76. sys.stdout.buffer = sys.stdout
  77. else:
  78. sys.stdout = MockStream()
  79. sys.stderr = MockStream()
  80. os.chdir(self.repo_path)
  81. result = cli.main(list(args))
  82. return result, sys.stdout.getvalue(), sys.stderr.getvalue()
  83. finally:
  84. sys.stdout = old_stdout
  85. sys.stderr = old_stderr
  86. os.chdir(old_cwd)
  87. class InitCommandTest(DulwichCliTestCase):
  88. """Tests for init command."""
  89. def test_init_basic(self):
  90. # Create a new directory for init
  91. new_repo_path = os.path.join(self.test_dir, "new_repo")
  92. result, stdout, stderr = self._run_cli("init", new_repo_path)
  93. self.assertTrue(os.path.exists(os.path.join(new_repo_path, ".git")))
  94. def test_init_bare(self):
  95. # Create a new directory for bare repo
  96. bare_repo_path = os.path.join(self.test_dir, "bare_repo")
  97. result, stdout, stderr = self._run_cli("init", "--bare", bare_repo_path)
  98. self.assertTrue(os.path.exists(os.path.join(bare_repo_path, "HEAD")))
  99. self.assertFalse(os.path.exists(os.path.join(bare_repo_path, ".git")))
  100. class HelperFunctionsTest(TestCase):
  101. """Tests for CLI helper functions."""
  102. def test_format_bytes(self):
  103. self.assertEqual("0.0 B", format_bytes(0))
  104. self.assertEqual("100.0 B", format_bytes(100))
  105. self.assertEqual("1.0 KB", format_bytes(1024))
  106. self.assertEqual("1.5 KB", format_bytes(1536))
  107. self.assertEqual("1.0 MB", format_bytes(1024 * 1024))
  108. self.assertEqual("1.0 GB", format_bytes(1024 * 1024 * 1024))
  109. self.assertEqual("1.0 TB", format_bytes(1024 * 1024 * 1024 * 1024))
  110. def test_launch_editor_with_cat(self):
  111. """Test launch_editor by using cat as the editor."""
  112. self.overrideEnv("GIT_EDITOR", "cat")
  113. result = launch_editor(b"Test template content")
  114. self.assertEqual(b"Test template content", result)
  115. class AddCommandTest(DulwichCliTestCase):
  116. """Tests for add command."""
  117. def test_add_single_file(self):
  118. # Create a file to add
  119. test_file = os.path.join(self.repo_path, "test.txt")
  120. with open(test_file, "w") as f:
  121. f.write("test content")
  122. result, stdout, stderr = self._run_cli("add", "test.txt")
  123. # Check that file is in index
  124. self.assertIn(b"test.txt", self.repo.open_index())
  125. def test_add_multiple_files(self):
  126. # Create multiple files
  127. for i in range(3):
  128. test_file = os.path.join(self.repo_path, f"test{i}.txt")
  129. with open(test_file, "w") as f:
  130. f.write(f"content {i}")
  131. result, stdout, stderr = self._run_cli(
  132. "add", "test0.txt", "test1.txt", "test2.txt"
  133. )
  134. index = self.repo.open_index()
  135. self.assertIn(b"test0.txt", index)
  136. self.assertIn(b"test1.txt", index)
  137. self.assertIn(b"test2.txt", index)
  138. class RmCommandTest(DulwichCliTestCase):
  139. """Tests for rm command."""
  140. def test_rm_file(self):
  141. # Create, add and commit a file first
  142. test_file = os.path.join(self.repo_path, "test.txt")
  143. with open(test_file, "w") as f:
  144. f.write("test content")
  145. self._run_cli("add", "test.txt")
  146. self._run_cli("commit", "--message=Add test file")
  147. # Now remove it from index and working directory
  148. result, stdout, stderr = self._run_cli("rm", "test.txt")
  149. # Check that file is not in index
  150. self.assertNotIn(b"test.txt", self.repo.open_index())
  151. class CommitCommandTest(DulwichCliTestCase):
  152. """Tests for commit command."""
  153. def test_commit_basic(self):
  154. # Create and add a file
  155. test_file = os.path.join(self.repo_path, "test.txt")
  156. with open(test_file, "w") as f:
  157. f.write("test content")
  158. self._run_cli("add", "test.txt")
  159. # Commit
  160. result, stdout, stderr = self._run_cli("commit", "--message=Initial commit")
  161. # Check that HEAD points to a commit
  162. self.assertIsNotNone(self.repo.head())
  163. def test_commit_all_flag(self):
  164. # Create initial commit
  165. test_file = os.path.join(self.repo_path, "test.txt")
  166. with open(test_file, "w") as f:
  167. f.write("initial content")
  168. self._run_cli("add", "test.txt")
  169. self._run_cli("commit", "--message=Initial commit")
  170. # Modify the file (don't stage it)
  171. with open(test_file, "w") as f:
  172. f.write("modified content")
  173. # Create another file and don't add it (untracked)
  174. untracked_file = os.path.join(self.repo_path, "untracked.txt")
  175. with open(untracked_file, "w") as f:
  176. f.write("untracked content")
  177. # Commit with -a flag should stage and commit the modified file,
  178. # but not the untracked file
  179. result, stdout, stderr = self._run_cli(
  180. "commit", "-a", "--message=Modified commit"
  181. )
  182. self.assertIsNotNone(self.repo.head())
  183. # Check that the modification was committed
  184. with open(test_file) as f:
  185. content = f.read()
  186. self.assertEqual(content, "modified content")
  187. # Check that untracked file is still untracked
  188. self.assertTrue(os.path.exists(untracked_file))
  189. def test_commit_all_flag_no_changes(self):
  190. # Create initial commit
  191. test_file = os.path.join(self.repo_path, "test.txt")
  192. with open(test_file, "w") as f:
  193. f.write("initial content")
  194. self._run_cli("add", "test.txt")
  195. self._run_cli("commit", "--message=Initial commit")
  196. # Try to commit with -a when there are no changes
  197. # This should still work (git allows this)
  198. result, stdout, stderr = self._run_cli(
  199. "commit", "-a", "--message=No changes commit"
  200. )
  201. self.assertIsNotNone(self.repo.head())
  202. def test_commit_all_flag_multiple_files(self):
  203. # Create initial commit with multiple files
  204. file1 = os.path.join(self.repo_path, "file1.txt")
  205. file2 = os.path.join(self.repo_path, "file2.txt")
  206. with open(file1, "w") as f:
  207. f.write("content1")
  208. with open(file2, "w") as f:
  209. f.write("content2")
  210. self._run_cli("add", "file1.txt", "file2.txt")
  211. self._run_cli("commit", "--message=Initial commit")
  212. # Modify both files
  213. with open(file1, "w") as f:
  214. f.write("modified content1")
  215. with open(file2, "w") as f:
  216. f.write("modified content2")
  217. # Create an untracked file
  218. untracked_file = os.path.join(self.repo_path, "untracked.txt")
  219. with open(untracked_file, "w") as f:
  220. f.write("untracked content")
  221. # Commit with -a should stage both modified files but not untracked
  222. result, stdout, stderr = self._run_cli(
  223. "commit", "-a", "--message=Modified both files"
  224. )
  225. self.assertIsNotNone(self.repo.head())
  226. # Verify modifications were committed
  227. with open(file1) as f:
  228. self.assertEqual(f.read(), "modified content1")
  229. with open(file2) as f:
  230. self.assertEqual(f.read(), "modified content2")
  231. # Verify untracked file still exists
  232. self.assertTrue(os.path.exists(untracked_file))
  233. @patch("dulwich.cli.launch_editor")
  234. def test_commit_editor_success(self, mock_editor):
  235. """Test commit with editor when user provides a message."""
  236. # Create and add a file
  237. test_file = os.path.join(self.repo_path, "test.txt")
  238. with open(test_file, "w") as f:
  239. f.write("test content")
  240. self._run_cli("add", "test.txt")
  241. # Mock editor to return a commit message
  242. mock_editor.return_value = b"My commit message\n\n# This is a comment\n"
  243. # Commit without --message flag
  244. result, stdout, stderr = self._run_cli("commit")
  245. # Check that HEAD points to a commit
  246. commit = self.repo[self.repo.head()]
  247. self.assertEqual(commit.message, b"My commit message")
  248. # Verify editor was called
  249. mock_editor.assert_called_once()
  250. @patch("dulwich.cli.launch_editor")
  251. def test_commit_editor_empty_message(self, mock_editor):
  252. """Test commit with editor when user provides empty message."""
  253. # Create and add a file
  254. test_file = os.path.join(self.repo_path, "test.txt")
  255. with open(test_file, "w") as f:
  256. f.write("test content")
  257. self._run_cli("add", "test.txt")
  258. # Mock editor to return only comments
  259. mock_editor.return_value = b"# All lines are comments\n# No actual message\n"
  260. # Commit without --message flag should fail with exit code 1
  261. result, stdout, stderr = self._run_cli("commit")
  262. self.assertEqual(result, 1)
  263. @patch("dulwich.cli.launch_editor")
  264. def test_commit_editor_unchanged_template(self, mock_editor):
  265. """Test commit with editor when user doesn't change the template."""
  266. # Create and add a file
  267. test_file = os.path.join(self.repo_path, "test.txt")
  268. with open(test_file, "w") as f:
  269. f.write("test content")
  270. self._run_cli("add", "test.txt")
  271. # Mock editor to return the exact template that was passed to it
  272. def return_unchanged_template(template):
  273. return template
  274. mock_editor.side_effect = return_unchanged_template
  275. # Commit without --message flag should fail with exit code 1
  276. result, stdout, stderr = self._run_cli("commit")
  277. self.assertEqual(result, 1)
  278. class LogCommandTest(DulwichCliTestCase):
  279. """Tests for log command."""
  280. def test_log_empty_repo(self):
  281. result, stdout, stderr = self._run_cli("log")
  282. # Empty repo should not crash
  283. def test_log_with_commits(self):
  284. # Create some commits
  285. c1, c2, c3 = build_commit_graph(
  286. self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
  287. )
  288. self.repo.refs[b"HEAD"] = c3.id
  289. result, stdout, stderr = self._run_cli("log")
  290. self.assertIn("Commit 3", stdout)
  291. self.assertIn("Commit 2", stdout)
  292. self.assertIn("Commit 1", stdout)
  293. def test_log_reverse(self):
  294. # Create some commits
  295. c1, c2, c3 = build_commit_graph(
  296. self.repo.object_store, [[1], [2, 1], [3, 1, 2]]
  297. )
  298. self.repo.refs[b"HEAD"] = c3.id
  299. result, stdout, stderr = self._run_cli("log", "--reverse")
  300. # Check order - commit 1 should appear before commit 3
  301. pos1 = stdout.index("Commit 1")
  302. pos3 = stdout.index("Commit 3")
  303. self.assertLess(pos1, pos3)
  304. class StatusCommandTest(DulwichCliTestCase):
  305. """Tests for status command."""
  306. def test_status_empty(self):
  307. result, stdout, stderr = self._run_cli("status")
  308. # Should not crash on empty repo
  309. def test_status_with_untracked(self):
  310. # Create an untracked file
  311. test_file = os.path.join(self.repo_path, "untracked.txt")
  312. with open(test_file, "w") as f:
  313. f.write("untracked content")
  314. result, stdout, stderr = self._run_cli("status")
  315. self.assertIn("Untracked files:", stdout)
  316. self.assertIn("untracked.txt", stdout)
  317. class BranchCommandTest(DulwichCliTestCase):
  318. """Tests for branch command."""
  319. def test_branch_create(self):
  320. # Create initial commit
  321. test_file = os.path.join(self.repo_path, "test.txt")
  322. with open(test_file, "w") as f:
  323. f.write("test")
  324. self._run_cli("add", "test.txt")
  325. self._run_cli("commit", "--message=Initial")
  326. # Create branch
  327. result, stdout, stderr = self._run_cli("branch", "test-branch")
  328. self.assertIn(b"refs/heads/test-branch", self.repo.refs.keys())
  329. def test_branch_delete(self):
  330. # Create initial commit and branch
  331. test_file = os.path.join(self.repo_path, "test.txt")
  332. with open(test_file, "w") as f:
  333. f.write("test")
  334. self._run_cli("add", "test.txt")
  335. self._run_cli("commit", "--message=Initial")
  336. self._run_cli("branch", "test-branch")
  337. # Delete branch
  338. result, stdout, stderr = self._run_cli("branch", "-d", "test-branch")
  339. self.assertNotIn(b"refs/heads/test-branch", self.repo.refs.keys())
  340. def test_branch_list_all(self):
  341. # Create initial commit
  342. test_file = os.path.join(self.repo_path, "test.txt")
  343. with open(test_file, "w") as f:
  344. f.write("test")
  345. self._run_cli("add", "test.txt")
  346. self._run_cli("commit", "--message=Initial")
  347. # Create local test branches
  348. self._run_cli("branch", "feature-1")
  349. self._run_cli("branch", "feature-2")
  350. # Setup a remote and create remote branches
  351. self.repo.refs[b"refs/remotes/origin/master"] = self.repo.refs[
  352. b"refs/heads/master"
  353. ]
  354. self.repo.refs[b"refs/remotes/origin/feature-remote"] = self.repo.refs[
  355. b"refs/heads/master"
  356. ]
  357. # Test --all listing
  358. result, stdout, stderr = self._run_cli("branch", "--all")
  359. self.assertEqual(result, 0)
  360. expected_branches = {
  361. "feature-1", # local branch
  362. "feature-2", # local branch
  363. "master", # local branch
  364. "origin/master", # remote branch
  365. "origin/feature-remote", # remote branch
  366. }
  367. lines = [line.strip() for line in stdout.splitlines()]
  368. # All branches from stdout
  369. all_branches = set(line for line in lines)
  370. self.assertEqual(all_branches, expected_branches)
  371. def test_branch_list_merged(self):
  372. # Create initial commit
  373. test_file = os.path.join(self.repo_path, "test.txt")
  374. with open(test_file, "w") as f:
  375. f.write("test")
  376. self._run_cli("add", "test.txt")
  377. self._run_cli("commit", "--message=Initial")
  378. master_sha = self.repo.refs[b"refs/heads/master"]
  379. # Create a merged branch (points to same commit as master)
  380. self.repo.refs[b"refs/heads/merged-branch"] = master_sha
  381. # Create a new branch with different content (not merged)
  382. test_file2 = os.path.join(self.repo_path, "test2.txt")
  383. with open(test_file2, "w") as f:
  384. f.write("test2")
  385. self._run_cli("add", "test2.txt")
  386. self._run_cli("commit", "--message=New branch commit")
  387. new_branch_sha = self.repo.refs[b"HEAD"]
  388. # Switch back to master
  389. self.repo.refs[b"HEAD"] = master_sha
  390. # Create a non-merged branch that points to the new branch commit
  391. self.repo.refs[b"refs/heads/non-merged-branch"] = new_branch_sha
  392. # Test --merged listing
  393. result, stdout, stderr = self._run_cli("branch", "--merged")
  394. self.assertEqual(result, 0)
  395. branches = [line.strip() for line in stdout.splitlines()]
  396. expected_branches = {"master", "merged-branch"}
  397. self.assertEqual(set(branches), expected_branches)
  398. def test_branch_list_no_merged(self):
  399. # Create initial commit
  400. test_file = os.path.join(self.repo_path, "test.txt")
  401. with open(test_file, "w") as f:
  402. f.write("test")
  403. self._run_cli("add", "test.txt")
  404. self._run_cli("commit", "--message=Initial")
  405. master_sha = self.repo.refs[b"refs/heads/master"]
  406. # Create a merged branch (points to same commit as master)
  407. self.repo.refs[b"refs/heads/merged-branch"] = master_sha
  408. # Create a new branch with different content (not merged)
  409. test_file2 = os.path.join(self.repo_path, "test2.txt")
  410. with open(test_file2, "w") as f:
  411. f.write("test2")
  412. self._run_cli("add", "test2.txt")
  413. self._run_cli("commit", "--message=New branch commit")
  414. new_branch_sha = self.repo.refs[b"HEAD"]
  415. # Switch back to master
  416. self.repo.refs[b"HEAD"] = master_sha
  417. # Create a non-merged branch that points to the new branch commit
  418. self.repo.refs[b"refs/heads/non-merged-branch"] = new_branch_sha
  419. # Test --no-merged listing
  420. result, stdout, stderr = self._run_cli("branch", "--no-merged")
  421. self.assertEqual(result, 0)
  422. branches = [line.strip() for line in stdout.splitlines()]
  423. expected_branches = {"non-merged-branch"}
  424. self.assertEqual(set(branches), expected_branches)
  425. def test_branch_list_remotes(self):
  426. # Create initial commit
  427. test_file = os.path.join(self.repo_path, "test.txt")
  428. with open(test_file, "w") as f:
  429. f.write("test")
  430. self._run_cli("add", "test.txt")
  431. self._run_cli("commit", "--message=Initial")
  432. # Setup a remote and create remote branches
  433. self.repo.refs[b"refs/remotes/origin/master"] = self.repo.refs[
  434. b"refs/heads/master"
  435. ]
  436. self.repo.refs[b"refs/remotes/origin/feature-remote-1"] = self.repo.refs[
  437. b"refs/heads/master"
  438. ]
  439. self.repo.refs[b"refs/remotes/origin/feature-remote-2"] = self.repo.refs[
  440. b"refs/heads/master"
  441. ]
  442. # Test --remotes listing
  443. result, stdout, stderr = self._run_cli("branch", "--remotes")
  444. self.assertEqual(result, 0)
  445. branches = [line.strip() for line in stdout.splitlines()]
  446. expected_branches = [
  447. "origin/feature-remote-1",
  448. "origin/feature-remote-2",
  449. "origin/master",
  450. ]
  451. self.assertEqual(branches, expected_branches)
  452. def test_branch_list_contains(self):
  453. # Create initial commit
  454. test_file = os.path.join(self.repo_path, "test.txt")
  455. with open(test_file, "w") as f:
  456. f.write("test")
  457. self._run_cli("add", "test.txt")
  458. self._run_cli("commit", "--message=Initial")
  459. initial_commit_sha = self.repo.refs[b"HEAD"]
  460. # Create first branch from initial commit
  461. self._run_cli("branch", "branch-1")
  462. # Make a new commit on master
  463. test_file2 = os.path.join(self.repo_path, "test2.txt")
  464. with open(test_file2, "w") as f:
  465. f.write("test2")
  466. self._run_cli("add", "test2.txt")
  467. self._run_cli("commit", "--message=Second commit")
  468. second_commit_sha = self.repo.refs[b"HEAD"]
  469. # Create second branch from current master (contains both commits)
  470. self._run_cli("branch", "branch-2")
  471. # Create third branch that doesn't contain the second commit
  472. # Switch to initial commit and create branch from there
  473. self.repo.refs[b"HEAD"] = initial_commit_sha
  474. self._run_cli("branch", "branch-3")
  475. # Switch back to master
  476. self.repo.refs[b"HEAD"] = second_commit_sha
  477. # Test --contains with second commit (should include master and branch-2)
  478. result, stdout, stderr = self._run_cli(
  479. "branch", "--contains", second_commit_sha.decode()
  480. )
  481. self.assertEqual(result, 0)
  482. branches = [line.strip() for line in stdout.splitlines()]
  483. expected_branches = {"master", "branch-2"}
  484. self.assertEqual(set(branches), expected_branches)
  485. # Test --contains with initial commit (should include all branches)
  486. result, stdout, stderr = self._run_cli(
  487. "branch", "--contains", initial_commit_sha.decode()
  488. )
  489. self.assertEqual(result, 0)
  490. branches = [line.strip() for line in stdout.splitlines()]
  491. expected_branches = {"master", "branch-1", "branch-2", "branch-3"}
  492. self.assertEqual(set(branches), expected_branches)
  493. # Test --contains without argument (uses HEAD, which is second commit)
  494. result, stdout, stderr = self._run_cli("branch", "--contains")
  495. self.assertEqual(result, 0)
  496. branches = [line.strip() for line in stdout.splitlines()]
  497. expected_branches = {"master", "branch-2"}
  498. self.assertEqual(set(branches), expected_branches)
  499. # Test with invalid commit hash
  500. result, stdout, stderr = self._run_cli("branch", "--contains", "invalid123")
  501. self.assertNotEqual(result, 0)
  502. self.assertIn("error: object name invalid123 not found", stderr)
  503. def test_branch_list_column(self):
  504. """Test branch --column formatting"""
  505. # Create initial commit
  506. test_file = os.path.join(self.repo_path, "test.txt")
  507. with open(test_file, "w") as f:
  508. f.write("test")
  509. self._run_cli("add", "test.txt")
  510. self._run_cli("commit", "--message=Initial")
  511. self._run_cli("branch", "feature-1")
  512. self._run_cli("branch", "feature-2")
  513. self._run_cli("branch", "feature-3")
  514. # Run branch --column
  515. result, stdout, stderr = self._run_cli("branch", "--all", "--column")
  516. self.assertEqual(result, 0)
  517. expected = ["feature-1", "feature-2", "feature-3"]
  518. for branch in expected:
  519. self.assertIn(branch, stdout)
  520. multiple_columns = any(
  521. sum(branch in line for branch in expected) > 1
  522. for line in stdout.strip().split("\n")
  523. )
  524. self.assertTrue(multiple_columns)
  525. class TestWriteColumns(TestCase):
  526. """Tests for write_columns function"""
  527. def setUp(self) -> None:
  528. super().setUp()
  529. self.original_stdout = sys.stdout
  530. self.original_get_terminal_size = os.get_terminal_size
  531. def tearDown(self):
  532. super().tearDown()
  533. sys.stdout = self.original_stdout
  534. os.get_terminal_size = self.original_get_terminal_size
  535. @patch("os.get_terminal_size")
  536. def test_basic_functionality(self, mock_terminal_size):
  537. """Test basic functionality with default terminal width."""
  538. mock_terminal_size.return_value.columns = 80
  539. with patch("sys.stdout.write") as mock_write:
  540. items = [b"main", b"dev", b"feature/branch-1"]
  541. write_columns(items)
  542. self.assertGreater(mock_write.call_count, 0)
  543. output_text = "".join(call.args[0] for call in mock_write.call_args_list)
  544. self.assertIn("main", output_text)
  545. self.assertIn("dev", output_text)
  546. self.assertIn("feature/branch-1", output_text)
  547. @patch("os.get_terminal_size")
  548. def test_narrow_terminal_single_column(self, mock_terminal_size):
  549. """Test with narrow terminal forcing single column."""
  550. mock_terminal_size.return_value.columns = 20
  551. with patch("sys.stdout.write") as mock_write:
  552. items = [b"main", b"dev", b"feature/branch-1"]
  553. write_columns(items)
  554. output_text = "".join(call.args[0] for call in mock_write.call_args_list)
  555. for item in items:
  556. self.assertIn(item.decode(), output_text)
  557. @patch("os.get_terminal_size")
  558. def test_wide_terminal_multiple_columns(self, mock_terminal_size):
  559. """Test with wide terminal allowing multiple columns."""
  560. mock_terminal_size.return_value.columns = 120
  561. with patch("sys.stdout.write") as mock_write:
  562. items = [
  563. b"main",
  564. b"dev",
  565. b"feature/branch-1",
  566. b"feature/branch-2",
  567. b"feature/branch-3",
  568. ]
  569. write_columns(items)
  570. output_text = "".join(call.args[0] for call in mock_write.call_args_list)
  571. for item in items:
  572. self.assertIn(item.decode(), output_text)
  573. @patch("os.get_terminal_size")
  574. def test_single_item(self, mock_terminal_size):
  575. """Test with single item."""
  576. mock_terminal_size.return_value.columns = 80
  577. with patch("sys.stdout.write") as mock_write:
  578. write_columns([b"single"])
  579. output_text = "".join(call.args[0] for call in mock_write.call_args_list)
  580. self.assertIn("single", output_text)
  581. self.assertTrue(output_text.endswith("\n"))
  582. def test_os_error_fallback(self):
  583. """Test fallback behavior when os.get_terminal_size raises OSError."""
  584. with patch("os.get_terminal_size", side_effect=OSError("No terminal")):
  585. with patch("sys.stdout.write") as mock_write:
  586. items = [b"main", b"dev"]
  587. write_columns(items)
  588. output_text = "".join(
  589. call.args[0] for call in mock_write.call_args_list
  590. )
  591. self.assertIn("main", output_text)
  592. self.assertIn("dev", output_text)
  593. @patch("os.get_terminal_size")
  594. def test_iterator_input(self, mock_terminal_size):
  595. """Test with iterator input instead of list."""
  596. mock_terminal_size.return_value.columns = 80
  597. with patch("sys.stdout.write") as mock_write:
  598. items = [b"main", b"dev", b"feature/branch-1"]
  599. items_iterator = iter(items)
  600. write_columns(items_iterator)
  601. output_text = "".join(call.args[0] for call in mock_write.call_args_list)
  602. for item in items:
  603. self.assertIn(item.decode(), output_text)
  604. @patch("os.get_terminal_size")
  605. def test_column_alignment(self, mock_terminal_size):
  606. """Test that columns are properly aligned."""
  607. mock_terminal_size.return_value.columns = 50
  608. with patch("sys.stdout.write") as mock_write:
  609. items = [b"short", b"medium_length", b"very_long______name"]
  610. write_columns(items)
  611. output_text = "".join(call.args[0] for call in mock_write.call_args_list)
  612. for item in items:
  613. self.assertIn(item.decode(), output_text)
  614. @patch("os.get_terminal_size")
  615. def test_columns_formatting(self, mock_terminal_size):
  616. """Test that items are formatted in columns within single line."""
  617. mock_terminal_size.return_value.columns = 80
  618. with patch("sys.stdout.write") as mock_write:
  619. items = [b"branch-1", b"branch-2", b"branch-3", b"branch-4", b"branch-5"]
  620. write_columns(items)
  621. output_text = "".join(call.args[0] for call in mock_write.call_args_list)
  622. self.assertEqual(output_text.count("\n"), 1)
  623. self.assertTrue(output_text.endswith("\n"))
  624. line = output_text.strip()
  625. for item in items:
  626. self.assertIn(item.decode(), line)
  627. @patch("os.get_terminal_size")
  628. def test_column_alignment_multiple_lines(self, mock_terminal_size):
  629. """Test that columns are properly aligned across multiple lines."""
  630. mock_terminal_size.return_value.columns = 60
  631. with patch("sys.stdout.write") as mock_write:
  632. items = [
  633. b"short",
  634. b"medium_length",
  635. b"very_long_branch_name",
  636. b"another",
  637. b"more",
  638. b"even_longer_branch_name_here",
  639. ]
  640. write_columns(items)
  641. output_text = "".join(call.args[0] for call in mock_write.call_args_list)
  642. lines = output_text.strip().split("\n")
  643. self.assertGreater(len(lines), 1)
  644. line_lengths = [len(line) for line in lines if line.strip()]
  645. for length in line_lengths:
  646. self.assertLessEqual(length, mock_terminal_size.return_value.columns)
  647. all_output = " ".join(lines)
  648. for item in items:
  649. self.assertIn(item.decode(), all_output)
  650. class CheckoutCommandTest(DulwichCliTestCase):
  651. """Tests for checkout command."""
  652. def test_checkout_branch(self):
  653. # Create initial commit and branch
  654. test_file = os.path.join(self.repo_path, "test.txt")
  655. with open(test_file, "w") as f:
  656. f.write("test")
  657. self._run_cli("add", "test.txt")
  658. self._run_cli("commit", "--message=Initial")
  659. self._run_cli("branch", "test-branch")
  660. # Checkout branch
  661. result, stdout, stderr = self._run_cli("checkout", "test-branch")
  662. self.assertEqual(
  663. self.repo.refs.read_ref(b"HEAD"), b"ref: refs/heads/test-branch"
  664. )
  665. class TagCommandTest(DulwichCliTestCase):
  666. """Tests for tag command."""
  667. def test_tag_create(self):
  668. # Create initial commit
  669. test_file = os.path.join(self.repo_path, "test.txt")
  670. with open(test_file, "w") as f:
  671. f.write("test")
  672. self._run_cli("add", "test.txt")
  673. self._run_cli("commit", "--message=Initial")
  674. # Create tag
  675. result, stdout, stderr = self._run_cli("tag", "v1.0")
  676. self.assertIn(b"refs/tags/v1.0", self.repo.refs.keys())
  677. class DiffCommandTest(DulwichCliTestCase):
  678. """Tests for diff command."""
  679. def test_diff_working_tree(self):
  680. # Create and commit a file
  681. test_file = os.path.join(self.repo_path, "test.txt")
  682. with open(test_file, "w") as f:
  683. f.write("initial content\n")
  684. self._run_cli("add", "test.txt")
  685. self._run_cli("commit", "--message=Initial")
  686. # Modify the file
  687. with open(test_file, "w") as f:
  688. f.write("initial content\nmodified\n")
  689. # Test unstaged diff
  690. result, stdout, stderr = self._run_cli("diff")
  691. self.assertIn("+modified", stdout)
  692. def test_diff_staged(self):
  693. # Create initial commit
  694. test_file = os.path.join(self.repo_path, "test.txt")
  695. with open(test_file, "w") as f:
  696. f.write("initial content\n")
  697. self._run_cli("add", "test.txt")
  698. self._run_cli("commit", "--message=Initial")
  699. # Modify and stage the file
  700. with open(test_file, "w") as f:
  701. f.write("initial content\nnew file\n")
  702. self._run_cli("add", "test.txt")
  703. # Test staged diff
  704. result, stdout, stderr = self._run_cli("diff", "--staged")
  705. self.assertIn("+new file", stdout)
  706. def test_diff_cached(self):
  707. # Create initial commit
  708. test_file = os.path.join(self.repo_path, "test.txt")
  709. with open(test_file, "w") as f:
  710. f.write("initial content\n")
  711. self._run_cli("add", "test.txt")
  712. self._run_cli("commit", "--message=Initial")
  713. # Modify and stage the file
  714. with open(test_file, "w") as f:
  715. f.write("initial content\nnew file\n")
  716. self._run_cli("add", "test.txt")
  717. # Test cached diff (alias for staged)
  718. result, stdout, stderr = self._run_cli("diff", "--cached")
  719. self.assertIn("+new file", stdout)
  720. def test_diff_commit(self):
  721. # Create two commits
  722. test_file = os.path.join(self.repo_path, "test.txt")
  723. with open(test_file, "w") as f:
  724. f.write("first version\n")
  725. self._run_cli("add", "test.txt")
  726. self._run_cli("commit", "--message=First")
  727. with open(test_file, "w") as f:
  728. f.write("first version\nsecond line\n")
  729. self._run_cli("add", "test.txt")
  730. self._run_cli("commit", "--message=Second")
  731. # Add working tree changes
  732. with open(test_file, "a") as f:
  733. f.write("working tree change\n")
  734. # Test single commit diff (should show working tree vs HEAD)
  735. result, stdout, stderr = self._run_cli("diff", "HEAD")
  736. self.assertIn("+working tree change", stdout)
  737. def test_diff_two_commits(self):
  738. # Create two commits
  739. test_file = os.path.join(self.repo_path, "test.txt")
  740. with open(test_file, "w") as f:
  741. f.write("first version\n")
  742. self._run_cli("add", "test.txt")
  743. self._run_cli("commit", "--message=First")
  744. # Get first commit SHA
  745. first_commit = self.repo.refs[b"HEAD"].decode()
  746. with open(test_file, "w") as f:
  747. f.write("first version\nsecond line\n")
  748. self._run_cli("add", "test.txt")
  749. self._run_cli("commit", "--message=Second")
  750. # Get second commit SHA
  751. second_commit = self.repo.refs[b"HEAD"].decode()
  752. # Test diff between two commits
  753. result, stdout, stderr = self._run_cli("diff", first_commit, second_commit)
  754. self.assertIn("+second line", stdout)
  755. def test_diff_commit_vs_working_tree(self):
  756. # Test that diff <commit> shows working tree vs commit (not commit vs parent)
  757. test_file = os.path.join(self.repo_path, "test.txt")
  758. with open(test_file, "w") as f:
  759. f.write("first version\n")
  760. self._run_cli("add", "test.txt")
  761. self._run_cli("commit", "--message=First")
  762. first_commit = self.repo.refs[b"HEAD"].decode()
  763. with open(test_file, "w") as f:
  764. f.write("first version\nsecond line\n")
  765. self._run_cli("add", "test.txt")
  766. self._run_cli("commit", "--message=Second")
  767. # Add changes to working tree
  768. with open(test_file, "w") as f:
  769. f.write("completely different\n")
  770. # diff <first_commit> should show working tree vs first commit
  771. result, stdout, stderr = self._run_cli("diff", first_commit)
  772. self.assertIn("-first version", stdout)
  773. self.assertIn("+completely different", stdout)
  774. def test_diff_with_paths(self):
  775. # Test path filtering
  776. # Create multiple files
  777. file1 = os.path.join(self.repo_path, "file1.txt")
  778. file2 = os.path.join(self.repo_path, "file2.txt")
  779. subdir = os.path.join(self.repo_path, "subdir")
  780. os.makedirs(subdir)
  781. file3 = os.path.join(subdir, "file3.txt")
  782. with open(file1, "w") as f:
  783. f.write("content1\n")
  784. with open(file2, "w") as f:
  785. f.write("content2\n")
  786. with open(file3, "w") as f:
  787. f.write("content3\n")
  788. self._run_cli("add", ".")
  789. self._run_cli("commit", "--message=Initial")
  790. # Modify all files
  791. with open(file1, "w") as f:
  792. f.write("modified1\n")
  793. with open(file2, "w") as f:
  794. f.write("modified2\n")
  795. with open(file3, "w") as f:
  796. f.write("modified3\n")
  797. # Test diff with specific file
  798. result, stdout, stderr = self._run_cli("diff", "--", "file1.txt")
  799. self.assertIn("file1.txt", stdout)
  800. self.assertNotIn("file2.txt", stdout)
  801. self.assertNotIn("file3.txt", stdout)
  802. # Test diff with directory
  803. result, stdout, stderr = self._run_cli("diff", "--", "subdir")
  804. self.assertNotIn("file1.txt", stdout)
  805. self.assertNotIn("file2.txt", stdout)
  806. self.assertIn("file3.txt", stdout)
  807. # Test staged diff with paths
  808. self._run_cli("add", "file1.txt")
  809. result, stdout, stderr = self._run_cli("diff", "--staged", "--", "file1.txt")
  810. self.assertIn("file1.txt", stdout)
  811. self.assertIn("+modified1", stdout)
  812. # Test diff with multiple paths (file2 and file3 are still unstaged)
  813. result, stdout, stderr = self._run_cli(
  814. "diff", "--", "file2.txt", "subdir/file3.txt"
  815. )
  816. self.assertIn("file2.txt", stdout)
  817. self.assertIn("file3.txt", stdout)
  818. self.assertNotIn("file1.txt", stdout)
  819. # Test diff with commit and paths
  820. first_commit = self.repo.refs[b"HEAD"].decode()
  821. with open(file1, "w") as f:
  822. f.write("newer1\n")
  823. result, stdout, stderr = self._run_cli("diff", first_commit, "--", "file1.txt")
  824. self.assertIn("file1.txt", stdout)
  825. self.assertIn("-content1", stdout)
  826. self.assertIn("+newer1", stdout)
  827. self.assertNotIn("file2.txt", stdout)
  828. class FilterBranchCommandTest(DulwichCliTestCase):
  829. """Tests for filter-branch command."""
  830. def setUp(self):
  831. super().setUp()
  832. # Create a more complex repository structure for testing
  833. # Create some files in subdirectories
  834. os.makedirs(os.path.join(self.repo_path, "subdir"))
  835. os.makedirs(os.path.join(self.repo_path, "other"))
  836. # Create files
  837. files = {
  838. "README.md": "# Test Repo",
  839. "subdir/file1.txt": "File in subdir",
  840. "subdir/file2.txt": "Another file in subdir",
  841. "other/file3.txt": "File in other dir",
  842. "root.txt": "File at root",
  843. }
  844. for path, content in files.items():
  845. file_path = os.path.join(self.repo_path, path)
  846. with open(file_path, "w") as f:
  847. f.write(content)
  848. # Add all files and create initial commit
  849. self._run_cli("add", ".")
  850. self._run_cli("commit", "--message=Initial commit")
  851. # Create a second commit modifying subdir
  852. with open(os.path.join(self.repo_path, "subdir/file1.txt"), "a") as f:
  853. f.write("\nModified content")
  854. self._run_cli("add", "subdir/file1.txt")
  855. self._run_cli("commit", "--message=Modify subdir file")
  856. # Create a third commit in other dir
  857. with open(os.path.join(self.repo_path, "other/file3.txt"), "a") as f:
  858. f.write("\nMore content")
  859. self._run_cli("add", "other/file3.txt")
  860. self._run_cli("commit", "--message=Modify other file")
  861. # Create a branch
  862. self._run_cli("branch", "test-branch")
  863. # Create a tag
  864. self._run_cli("tag", "v1.0")
  865. def test_filter_branch_subdirectory_filter(self):
  866. """Test filter-branch with subdirectory filter."""
  867. # Run filter-branch to extract only the subdir
  868. with self.assertLogs("dulwich.cli", level="INFO") as cm:
  869. result, stdout, stderr = self._run_cli(
  870. "filter-branch", "--subdirectory-filter", "subdir"
  871. )
  872. # Check that the operation succeeded
  873. self.assertEqual(result, 0)
  874. log_output = "\n".join(cm.output)
  875. self.assertIn("Rewrite HEAD", log_output)
  876. # filter-branch rewrites history but doesn't update working tree
  877. # We need to check the commit contents, not the working tree
  878. # Reset to the rewritten HEAD to update working tree
  879. self._run_cli("reset", "--hard", "HEAD")
  880. # Now check that only files from subdir remain at root level
  881. self.assertTrue(os.path.exists(os.path.join(self.repo_path, "file1.txt")))
  882. self.assertTrue(os.path.exists(os.path.join(self.repo_path, "file2.txt")))
  883. self.assertFalse(os.path.exists(os.path.join(self.repo_path, "README.md")))
  884. self.assertFalse(os.path.exists(os.path.join(self.repo_path, "root.txt")))
  885. self.assertFalse(os.path.exists(os.path.join(self.repo_path, "other")))
  886. self.assertFalse(os.path.exists(os.path.join(self.repo_path, "subdir")))
  887. # Check that original refs were backed up
  888. original_refs = [
  889. ref for ref in self.repo.refs.keys() if ref.startswith(b"refs/original/")
  890. ]
  891. self.assertTrue(
  892. len(original_refs) > 0, "No original refs found after filter-branch"
  893. )
  894. @skipIf(sys.platform == "win32", "sed command not available on Windows")
  895. def test_filter_branch_msg_filter(self):
  896. """Test filter-branch with message filter."""
  897. # Run filter-branch to prepend [FILTERED] to commit messages
  898. result, stdout, stderr = self._run_cli(
  899. "filter-branch", "--msg-filter", "sed 's/^/[FILTERED] /'"
  900. )
  901. self.assertEqual(result, 0)
  902. # Check that commit messages were modified
  903. result, stdout, stderr = self._run_cli("log")
  904. self.assertIn("[FILTERED] Modify other file", stdout)
  905. self.assertIn("[FILTERED] Modify subdir file", stdout)
  906. self.assertIn("[FILTERED] Initial commit", stdout)
  907. def test_filter_branch_env_filter(self):
  908. """Test filter-branch with environment filter."""
  909. # Run filter-branch to change author email
  910. env_filter = """
  911. if [ "$GIT_AUTHOR_EMAIL" = "test@example.com" ]; then
  912. export GIT_AUTHOR_EMAIL="filtered@example.com"
  913. fi
  914. """
  915. result, stdout, stderr = self._run_cli(
  916. "filter-branch", "--env-filter", env_filter
  917. )
  918. self.assertEqual(result, 0)
  919. def test_filter_branch_prune_empty(self):
  920. """Test filter-branch with prune-empty option."""
  921. # Create a commit that only touches files outside subdir
  922. with open(os.path.join(self.repo_path, "root.txt"), "a") as f:
  923. f.write("\nNew line")
  924. self._run_cli("add", "root.txt")
  925. self._run_cli("commit", "--message=Modify root file only")
  926. # Run filter-branch to extract subdir with prune-empty
  927. result, stdout, stderr = self._run_cli(
  928. "filter-branch", "--subdirectory-filter", "subdir", "--prune-empty"
  929. )
  930. self.assertEqual(result, 0)
  931. # The last commit should have been pruned
  932. result, stdout, stderr = self._run_cli("log")
  933. self.assertNotIn("Modify root file only", stdout)
  934. @skipIf(sys.platform == "win32", "sed command not available on Windows")
  935. def test_filter_branch_force(self):
  936. """Test filter-branch with force option."""
  937. # Run filter-branch once with a filter that actually changes something
  938. result, stdout, stderr = self._run_cli(
  939. "filter-branch", "--msg-filter", "sed 's/^/[TEST] /'"
  940. )
  941. self.assertEqual(result, 0)
  942. # Check that backup refs were created
  943. # The implementation backs up refs under refs/original/
  944. original_refs = [
  945. ref for ref in self.repo.refs.keys() if ref.startswith(b"refs/original/")
  946. ]
  947. self.assertTrue(len(original_refs) > 0, "No original refs found")
  948. # Run again without force - should fail
  949. with self.assertLogs("dulwich.cli", level="ERROR") as cm:
  950. result, stdout, stderr = self._run_cli(
  951. "filter-branch", "--msg-filter", "sed 's/^/[TEST2] /'"
  952. )
  953. self.assertEqual(result, 1)
  954. log_output = "\n".join(cm.output)
  955. self.assertIn("Cannot create a new backup", log_output)
  956. self.assertIn("refs/original", log_output)
  957. # Run with force - should succeed
  958. result, stdout, stderr = self._run_cli(
  959. "filter-branch", "--force", "--msg-filter", "sed 's/^/[TEST3] /'"
  960. )
  961. self.assertEqual(result, 0)
  962. @skipIf(sys.platform == "win32", "sed command not available on Windows")
  963. def test_filter_branch_specific_branch(self):
  964. """Test filter-branch on a specific branch."""
  965. # Switch to test-branch and add a commit
  966. self._run_cli("checkout", "test-branch")
  967. with open(os.path.join(self.repo_path, "branch-file.txt"), "w") as f:
  968. f.write("Branch specific file")
  969. self._run_cli("add", "branch-file.txt")
  970. self._run_cli("commit", "--message=Branch commit")
  971. # Run filter-branch on the test-branch
  972. with self.assertLogs("dulwich.cli", level="INFO") as cm:
  973. result, stdout, stderr = self._run_cli(
  974. "filter-branch", "--msg-filter", "sed 's/^/[BRANCH] /'", "test-branch"
  975. )
  976. self.assertEqual(result, 0)
  977. log_output = "\n".join(cm.output)
  978. self.assertIn("Ref 'refs/heads/test-branch' was rewritten", log_output)
  979. # Check that only test-branch was modified
  980. result, stdout, stderr = self._run_cli("log")
  981. self.assertIn("[BRANCH] Branch commit", stdout)
  982. # Switch to master and check it wasn't modified
  983. self._run_cli("checkout", "master")
  984. result, stdout, stderr = self._run_cli("log")
  985. self.assertNotIn("[BRANCH]", stdout)
  986. def test_filter_branch_tree_filter(self):
  987. """Test filter-branch with tree filter."""
  988. # Use a tree filter to remove a specific file
  989. tree_filter = "rm -f root.txt"
  990. result, stdout, stderr = self._run_cli(
  991. "filter-branch", "--tree-filter", tree_filter
  992. )
  993. self.assertEqual(result, 0)
  994. # Check that the file was removed from the latest commit
  995. # We need to check the commit tree, not the working directory
  996. result, stdout, stderr = self._run_cli("ls-tree", "HEAD")
  997. self.assertNotIn("root.txt", stdout)
  998. def test_filter_branch_index_filter(self):
  999. """Test filter-branch with index filter."""
  1000. # Use an index filter to remove a file from the index
  1001. index_filter = "git rm --cached --ignore-unmatch root.txt"
  1002. result, stdout, stderr = self._run_cli(
  1003. "filter-branch", "--index-filter", index_filter
  1004. )
  1005. self.assertEqual(result, 0)
  1006. def test_filter_branch_parent_filter(self):
  1007. """Test filter-branch with parent filter."""
  1008. # Create a merge commit first
  1009. self._run_cli("checkout", "HEAD", "-b", "feature")
  1010. with open(os.path.join(self.repo_path, "feature.txt"), "w") as f:
  1011. f.write("Feature")
  1012. self._run_cli("add", "feature.txt")
  1013. self._run_cli("commit", "--message=Feature commit")
  1014. self._run_cli("checkout", "master")
  1015. self._run_cli("merge", "feature", "--message=Merge feature")
  1016. # Use parent filter to linearize history (remove second parent)
  1017. parent_filter = "cut -d' ' -f1"
  1018. result, stdout, stderr = self._run_cli(
  1019. "filter-branch", "--parent-filter", parent_filter
  1020. )
  1021. self.assertEqual(result, 0)
  1022. def test_filter_branch_commit_filter(self):
  1023. """Test filter-branch with commit filter."""
  1024. # Use commit filter to skip commits with certain messages
  1025. commit_filter = """
  1026. if grep -q "Modify other" <<< "$GIT_COMMIT_MESSAGE"; then
  1027. skip_commit "$@"
  1028. else
  1029. git commit-tree "$@"
  1030. fi
  1031. """
  1032. result, stdout, stderr = self._run_cli(
  1033. "filter-branch", "--commit-filter", commit_filter
  1034. )
  1035. # Note: This test may fail because the commit filter syntax is simplified
  1036. # In real Git, skip_commit is a function, but our implementation may differ
  1037. def test_filter_branch_tag_name_filter(self):
  1038. """Test filter-branch with tag name filter."""
  1039. # Run filter-branch with tag name filter to rename tags
  1040. result, stdout, stderr = self._run_cli(
  1041. "filter-branch",
  1042. "--tag-name-filter",
  1043. "sed 's/^v/version-/'",
  1044. "--msg-filter",
  1045. "cat",
  1046. )
  1047. self.assertEqual(result, 0)
  1048. # Check that tag was renamed
  1049. self.assertIn(b"refs/tags/version-1.0", self.repo.refs.keys())
  1050. def test_filter_branch_errors(self):
  1051. """Test filter-branch error handling."""
  1052. # Test with invalid subdirectory
  1053. result, stdout, stderr = self._run_cli(
  1054. "filter-branch", "--subdirectory-filter", "nonexistent"
  1055. )
  1056. # Should still succeed but produce empty history
  1057. self.assertEqual(result, 0)
  1058. def test_filter_branch_no_args(self):
  1059. """Test filter-branch with no arguments."""
  1060. # Should work as no-op
  1061. result, stdout, stderr = self._run_cli("filter-branch")
  1062. self.assertEqual(result, 0)
  1063. class ShowCommandTest(DulwichCliTestCase):
  1064. """Tests for show command."""
  1065. def test_show_commit(self):
  1066. # Create a commit
  1067. test_file = os.path.join(self.repo_path, "test.txt")
  1068. with open(test_file, "w") as f:
  1069. f.write("test content")
  1070. self._run_cli("add", "test.txt")
  1071. self._run_cli("commit", "--message=Test commit")
  1072. result, stdout, stderr = self._run_cli("show", "HEAD")
  1073. self.assertIn("Test commit", stdout)
  1074. class FormatPatchCommandTest(DulwichCliTestCase):
  1075. """Tests for format-patch command."""
  1076. def test_format_patch_single_commit(self):
  1077. # Create a commit with actual content
  1078. from dulwich.objects import Blob, Tree
  1079. # Initial commit
  1080. tree1 = Tree()
  1081. self.repo.object_store.add_object(tree1)
  1082. self.repo.get_worktree().commit(
  1083. message=b"Initial commit",
  1084. tree=tree1.id,
  1085. )
  1086. # Second commit with a file
  1087. blob = Blob.from_string(b"Hello, World!\n")
  1088. self.repo.object_store.add_object(blob)
  1089. tree2 = Tree()
  1090. tree2.add(b"hello.txt", 0o100644, blob.id)
  1091. self.repo.object_store.add_object(tree2)
  1092. self.repo.get_worktree().commit(
  1093. message=b"Add hello.txt",
  1094. tree=tree2.id,
  1095. )
  1096. # Test format-patch for last commit
  1097. with self.assertLogs("dulwich.cli", level="INFO") as cm:
  1098. result, stdout, stderr = self._run_cli("format-patch", "-n", "1")
  1099. self.assertEqual(result, None)
  1100. log_output = "\n".join(cm.output)
  1101. self.assertIn("0001-Add-hello.txt.patch", log_output)
  1102. # Check patch contents
  1103. patch_file = os.path.join(self.repo_path, "0001-Add-hello.txt.patch")
  1104. with open(patch_file, "rb") as f:
  1105. content = f.read()
  1106. # Check header
  1107. self.assertIn(b"Subject: [PATCH 1/1] Add hello.txt", content)
  1108. self.assertIn(b"From:", content)
  1109. self.assertIn(b"Date:", content)
  1110. # Check diff content
  1111. self.assertIn(b"diff --git a/hello.txt b/hello.txt", content)
  1112. self.assertIn(b"new file mode", content)
  1113. self.assertIn(b"+Hello, World!", content)
  1114. # Check footer
  1115. self.assertIn(b"-- \nDulwich", content)
  1116. # Clean up
  1117. os.remove(patch_file)
  1118. def test_format_patch_multiple_commits(self):
  1119. from dulwich.objects import Blob, Tree
  1120. # Initial commit
  1121. tree1 = Tree()
  1122. self.repo.object_store.add_object(tree1)
  1123. self.repo.get_worktree().commit(
  1124. message=b"Initial commit",
  1125. tree=tree1.id,
  1126. )
  1127. # Second commit
  1128. blob1 = Blob.from_string(b"File 1 content\n")
  1129. self.repo.object_store.add_object(blob1)
  1130. tree2 = Tree()
  1131. tree2.add(b"file1.txt", 0o100644, blob1.id)
  1132. self.repo.object_store.add_object(tree2)
  1133. self.repo.get_worktree().commit(
  1134. message=b"Add file1.txt",
  1135. tree=tree2.id,
  1136. )
  1137. # Third commit
  1138. blob2 = Blob.from_string(b"File 2 content\n")
  1139. self.repo.object_store.add_object(blob2)
  1140. tree3 = Tree()
  1141. tree3.add(b"file1.txt", 0o100644, blob1.id)
  1142. tree3.add(b"file2.txt", 0o100644, blob2.id)
  1143. self.repo.object_store.add_object(tree3)
  1144. self.repo.get_worktree().commit(
  1145. message=b"Add file2.txt",
  1146. tree=tree3.id,
  1147. )
  1148. # Test format-patch for last 2 commits
  1149. with self.assertLogs("dulwich.cli", level="INFO") as cm:
  1150. result, stdout, stderr = self._run_cli("format-patch", "-n", "2")
  1151. self.assertEqual(result, None)
  1152. log_output = "\n".join(cm.output)
  1153. self.assertIn("0001-Add-file1.txt.patch", log_output)
  1154. self.assertIn("0002-Add-file2.txt.patch", log_output)
  1155. # Check first patch
  1156. with open(os.path.join(self.repo_path, "0001-Add-file1.txt.patch"), "rb") as f:
  1157. content = f.read()
  1158. self.assertIn(b"Subject: [PATCH 1/2] Add file1.txt", content)
  1159. self.assertIn(b"+File 1 content", content)
  1160. # Check second patch
  1161. with open(os.path.join(self.repo_path, "0002-Add-file2.txt.patch"), "rb") as f:
  1162. content = f.read()
  1163. self.assertIn(b"Subject: [PATCH 2/2] Add file2.txt", content)
  1164. self.assertIn(b"+File 2 content", content)
  1165. # Clean up
  1166. os.remove(os.path.join(self.repo_path, "0001-Add-file1.txt.patch"))
  1167. os.remove(os.path.join(self.repo_path, "0002-Add-file2.txt.patch"))
  1168. def test_format_patch_output_directory(self):
  1169. from dulwich.objects import Blob, Tree
  1170. # Create a commit
  1171. blob = Blob.from_string(b"Test content\n")
  1172. self.repo.object_store.add_object(blob)
  1173. tree = Tree()
  1174. tree.add(b"test.txt", 0o100644, blob.id)
  1175. self.repo.object_store.add_object(tree)
  1176. self.repo.get_worktree().commit(
  1177. message=b"Test commit",
  1178. tree=tree.id,
  1179. )
  1180. # Create output directory
  1181. output_dir = os.path.join(self.test_dir, "patches")
  1182. os.makedirs(output_dir)
  1183. # Test format-patch with output directory
  1184. result, stdout, stderr = self._run_cli(
  1185. "format-patch", "-o", output_dir, "-n", "1"
  1186. )
  1187. self.assertEqual(result, None)
  1188. # Check that file was created in output directory with correct content
  1189. patch_file = os.path.join(output_dir, "0001-Test-commit.patch")
  1190. self.assertTrue(os.path.exists(patch_file))
  1191. with open(patch_file, "rb") as f:
  1192. content = f.read()
  1193. self.assertIn(b"Subject: [PATCH 1/1] Test commit", content)
  1194. self.assertIn(b"+Test content", content)
  1195. def test_format_patch_commit_range(self):
  1196. from dulwich.objects import Blob, Tree
  1197. # Create commits with actual file changes
  1198. commits = []
  1199. trees = []
  1200. # Initial empty commit
  1201. tree0 = Tree()
  1202. self.repo.object_store.add_object(tree0)
  1203. trees.append(tree0)
  1204. c0 = self.repo.get_worktree().commit(
  1205. message=b"Initial commit",
  1206. tree=tree0.id,
  1207. )
  1208. commits.append(c0)
  1209. # Add three files in separate commits
  1210. for i in range(1, 4):
  1211. blob = Blob.from_string(f"Content {i}\n".encode())
  1212. self.repo.object_store.add_object(blob)
  1213. tree = Tree()
  1214. # Copy previous files
  1215. for j in range(1, i):
  1216. prev_blob_id = trees[j][f"file{j}.txt".encode()][1]
  1217. tree.add(f"file{j}.txt".encode(), 0o100644, prev_blob_id)
  1218. # Add new file
  1219. tree.add(f"file{i}.txt".encode(), 0o100644, blob.id)
  1220. self.repo.object_store.add_object(tree)
  1221. trees.append(tree)
  1222. c = self.repo.get_worktree().commit(
  1223. message=f"Add file{i}.txt".encode(),
  1224. tree=tree.id,
  1225. )
  1226. commits.append(c)
  1227. # Test format-patch with commit range (should get commits 2 and 3)
  1228. with self.assertLogs("dulwich.cli", level="INFO") as cm:
  1229. result, stdout, stderr = self._run_cli(
  1230. "format-patch", f"{commits[1].decode()}..{commits[3].decode()}"
  1231. )
  1232. self.assertEqual(result, None)
  1233. # Should create patches for commits 2 and 3
  1234. log_output = "\n".join(cm.output)
  1235. self.assertIn("0001-Add-file2.txt.patch", log_output)
  1236. self.assertIn("0002-Add-file3.txt.patch", log_output)
  1237. # Verify patch contents
  1238. with open(os.path.join(self.repo_path, "0001-Add-file2.txt.patch"), "rb") as f:
  1239. content = f.read()
  1240. self.assertIn(b"Subject: [PATCH 1/2] Add file2.txt", content)
  1241. self.assertIn(b"+Content 2", content)
  1242. self.assertNotIn(b"file3.txt", content) # Should not include file3
  1243. with open(os.path.join(self.repo_path, "0002-Add-file3.txt.patch"), "rb") as f:
  1244. content = f.read()
  1245. self.assertIn(b"Subject: [PATCH 2/2] Add file3.txt", content)
  1246. self.assertIn(b"+Content 3", content)
  1247. self.assertNotIn(b"file2.txt", content) # Should not modify file2
  1248. # Clean up
  1249. os.remove(os.path.join(self.repo_path, "0001-Add-file2.txt.patch"))
  1250. os.remove(os.path.join(self.repo_path, "0002-Add-file3.txt.patch"))
  1251. def test_format_patch_stdout(self):
  1252. from dulwich.objects import Blob, Tree
  1253. # Create a commit with modified file
  1254. tree1 = Tree()
  1255. blob1 = Blob.from_string(b"Original content\n")
  1256. self.repo.object_store.add_object(blob1)
  1257. tree1.add(b"file.txt", 0o100644, blob1.id)
  1258. self.repo.object_store.add_object(tree1)
  1259. self.repo.get_worktree().commit(
  1260. message=b"Initial commit",
  1261. tree=tree1.id,
  1262. )
  1263. tree2 = Tree()
  1264. blob2 = Blob.from_string(b"Modified content\n")
  1265. self.repo.object_store.add_object(blob2)
  1266. tree2.add(b"file.txt", 0o100644, blob2.id)
  1267. self.repo.object_store.add_object(tree2)
  1268. self.repo.get_worktree().commit(
  1269. message=b"Modify file.txt",
  1270. tree=tree2.id,
  1271. )
  1272. # Mock stdout as a BytesIO for binary output
  1273. stdout_stream = io.BytesIO()
  1274. stdout_stream.buffer = stdout_stream
  1275. # Run command with --stdout
  1276. old_stdout = sys.stdout
  1277. old_stderr = sys.stderr
  1278. old_cwd = os.getcwd()
  1279. try:
  1280. sys.stdout = stdout_stream
  1281. sys.stderr = io.StringIO()
  1282. os.chdir(self.repo_path)
  1283. cli.main(["format-patch", "--stdout", "-n", "1"])
  1284. finally:
  1285. sys.stdout = old_stdout
  1286. sys.stderr = old_stderr
  1287. os.chdir(old_cwd)
  1288. # Check output
  1289. stdout_stream.seek(0)
  1290. output = stdout_stream.read()
  1291. self.assertIn(b"Subject: [PATCH 1/1] Modify file.txt", output)
  1292. self.assertIn(b"diff --git a/file.txt b/file.txt", output)
  1293. self.assertIn(b"-Original content", output)
  1294. self.assertIn(b"+Modified content", output)
  1295. self.assertIn(b"-- \nDulwich", output)
  1296. def test_format_patch_empty_repo(self):
  1297. # Test with empty repository
  1298. result, stdout, stderr = self._run_cli("format-patch", "-n", "5")
  1299. self.assertEqual(result, None)
  1300. # Should produce no output for empty repo
  1301. self.assertEqual(stdout.strip(), "")
  1302. class FetchPackCommandTest(DulwichCliTestCase):
  1303. """Tests for fetch-pack command."""
  1304. @patch("dulwich.cli.get_transport_and_path")
  1305. def test_fetch_pack_basic(self, mock_transport):
  1306. # Mock the transport
  1307. mock_client = MagicMock()
  1308. mock_transport.return_value = (mock_client, "/path/to/repo")
  1309. mock_client.fetch.return_value = None
  1310. result, stdout, stderr = self._run_cli(
  1311. "fetch-pack", "git://example.com/repo.git"
  1312. )
  1313. mock_client.fetch.assert_called_once()
  1314. class LsRemoteCommandTest(DulwichCliTestCase):
  1315. """Tests for ls-remote command."""
  1316. def test_ls_remote_basic(self):
  1317. # Create a commit
  1318. test_file = os.path.join(self.repo_path, "test.txt")
  1319. with open(test_file, "w") as f:
  1320. f.write("test")
  1321. self._run_cli("add", "test.txt")
  1322. self._run_cli("commit", "--message=Initial")
  1323. # Test basic ls-remote
  1324. result, stdout, stderr = self._run_cli("ls-remote", self.repo_path)
  1325. lines = stdout.strip().split("\n")
  1326. self.assertTrue(any("HEAD" in line for line in lines))
  1327. self.assertTrue(any("refs/heads/master" in line for line in lines))
  1328. def test_ls_remote_symref(self):
  1329. # Create a commit
  1330. test_file = os.path.join(self.repo_path, "test.txt")
  1331. with open(test_file, "w") as f:
  1332. f.write("test")
  1333. self._run_cli("add", "test.txt")
  1334. self._run_cli("commit", "--message=Initial")
  1335. # Test ls-remote with --symref option
  1336. result, stdout, stderr = self._run_cli("ls-remote", "--symref", self.repo_path)
  1337. lines = stdout.strip().split("\n")
  1338. # Should show symref for HEAD in exact format: "ref: refs/heads/master\tHEAD"
  1339. expected_line = "ref: refs/heads/master\tHEAD"
  1340. self.assertIn(
  1341. expected_line,
  1342. lines,
  1343. f"Expected line '{expected_line}' not found in output: {lines}",
  1344. )
  1345. class PullCommandTest(DulwichCliTestCase):
  1346. """Tests for pull command."""
  1347. @patch("dulwich.porcelain.pull")
  1348. def test_pull_basic(self, mock_pull):
  1349. result, stdout, stderr = self._run_cli("pull", "origin")
  1350. mock_pull.assert_called_once()
  1351. @patch("dulwich.porcelain.pull")
  1352. def test_pull_with_refspec(self, mock_pull):
  1353. result, stdout, stderr = self._run_cli("pull", "origin", "master")
  1354. mock_pull.assert_called_once()
  1355. class PushCommandTest(DulwichCliTestCase):
  1356. """Tests for push command."""
  1357. @patch("dulwich.porcelain.push")
  1358. def test_push_basic(self, mock_push):
  1359. result, stdout, stderr = self._run_cli("push", "origin")
  1360. mock_push.assert_called_once()
  1361. @patch("dulwich.porcelain.push")
  1362. def test_push_force(self, mock_push):
  1363. result, stdout, stderr = self._run_cli("push", "-f", "origin")
  1364. mock_push.assert_called_with(".", "origin", None, force=True)
  1365. class ArchiveCommandTest(DulwichCliTestCase):
  1366. """Tests for archive command."""
  1367. def test_archive_basic(self):
  1368. # Create a commit
  1369. test_file = os.path.join(self.repo_path, "test.txt")
  1370. with open(test_file, "w") as f:
  1371. f.write("test content")
  1372. self._run_cli("add", "test.txt")
  1373. self._run_cli("commit", "--message=Initial")
  1374. # Archive produces binary output, so use BytesIO
  1375. result, stdout, stderr = self._run_cli(
  1376. "archive", "HEAD", stdout_stream=io.BytesIO()
  1377. )
  1378. # Should complete without error and produce some binary output
  1379. self.assertIsInstance(stdout, bytes)
  1380. self.assertGreater(len(stdout), 0)
  1381. class ForEachRefCommandTest(DulwichCliTestCase):
  1382. """Tests for for-each-ref command."""
  1383. def test_for_each_ref(self):
  1384. # Create a commit
  1385. test_file = os.path.join(self.repo_path, "test.txt")
  1386. with open(test_file, "w") as f:
  1387. f.write("test")
  1388. self._run_cli("add", "test.txt")
  1389. self._run_cli("commit", "--message=Initial")
  1390. with self.assertLogs("dulwich.cli", level="INFO") as cm:
  1391. result, stdout, stderr = self._run_cli("for-each-ref")
  1392. log_output = "\n".join(cm.output)
  1393. # Just check that we have some refs output and it contains refs/heads
  1394. self.assertTrue(len(cm.output) > 0, "Expected some ref output")
  1395. self.assertIn("refs/heads/", log_output)
  1396. class PackRefsCommandTest(DulwichCliTestCase):
  1397. """Tests for pack-refs command."""
  1398. def test_pack_refs(self):
  1399. # Create some refs
  1400. test_file = os.path.join(self.repo_path, "test.txt")
  1401. with open(test_file, "w") as f:
  1402. f.write("test")
  1403. self._run_cli("add", "test.txt")
  1404. self._run_cli("commit", "--message=Initial")
  1405. self._run_cli("branch", "test-branch")
  1406. result, stdout, stderr = self._run_cli("pack-refs", "--all")
  1407. # Check that packed-refs file exists
  1408. self.assertTrue(
  1409. os.path.exists(os.path.join(self.repo_path, ".git", "packed-refs"))
  1410. )
  1411. class SubmoduleCommandTest(DulwichCliTestCase):
  1412. """Tests for submodule commands."""
  1413. def test_submodule_list(self):
  1414. # Create an initial commit so repo has a HEAD
  1415. test_file = os.path.join(self.repo_path, "test.txt")
  1416. with open(test_file, "w") as f:
  1417. f.write("test")
  1418. self._run_cli("add", "test.txt")
  1419. self._run_cli("commit", "--message=Initial")
  1420. result, stdout, stderr = self._run_cli("submodule")
  1421. # Should not crash on repo without submodules
  1422. def test_submodule_init(self):
  1423. # Create .gitmodules file for init to work
  1424. gitmodules = os.path.join(self.repo_path, ".gitmodules")
  1425. with open(gitmodules, "w") as f:
  1426. f.write("") # Empty .gitmodules file
  1427. result, stdout, stderr = self._run_cli("submodule", "init")
  1428. # Should not crash
  1429. class StashCommandTest(DulwichCliTestCase):
  1430. """Tests for stash commands."""
  1431. def test_stash_list_empty(self):
  1432. result, stdout, stderr = self._run_cli("stash", "list")
  1433. # Should not crash on empty stash
  1434. def test_stash_push_pop(self):
  1435. # Create a file and modify it
  1436. test_file = os.path.join(self.repo_path, "test.txt")
  1437. with open(test_file, "w") as f:
  1438. f.write("initial")
  1439. self._run_cli("add", "test.txt")
  1440. self._run_cli("commit", "--message=Initial")
  1441. # Modify file
  1442. with open(test_file, "w") as f:
  1443. f.write("modified")
  1444. # Stash changes
  1445. with self.assertLogs("dulwich.cli", level="INFO") as cm:
  1446. result, stdout, stderr = self._run_cli("stash", "push")
  1447. self.assertIn("Saved working directory", cm.output[0])
  1448. # Note: Dulwich stash doesn't currently update the working tree
  1449. # so the file remains modified after stash push
  1450. # Note: stash pop is not fully implemented in Dulwich yet
  1451. # so we only test stash push here
  1452. class MergeCommandTest(DulwichCliTestCase):
  1453. """Tests for merge command."""
  1454. def test_merge_basic(self):
  1455. # Create initial commit
  1456. test_file = os.path.join(self.repo_path, "test.txt")
  1457. with open(test_file, "w") as f:
  1458. f.write("initial")
  1459. self._run_cli("add", "test.txt")
  1460. self._run_cli("commit", "--message=Initial")
  1461. # Create and checkout new branch
  1462. self._run_cli("branch", "feature")
  1463. self._run_cli("checkout", "feature")
  1464. # Make changes in feature branch
  1465. with open(test_file, "w") as f:
  1466. f.write("feature changes")
  1467. self._run_cli("add", "test.txt")
  1468. self._run_cli("commit", "--message=Feature commit")
  1469. # Go back to main
  1470. self._run_cli("checkout", "master")
  1471. # Merge feature branch
  1472. result, stdout, stderr = self._run_cli("merge", "feature")
  1473. class HelpCommandTest(DulwichCliTestCase):
  1474. """Tests for help command."""
  1475. def test_help_basic(self):
  1476. with self.assertLogs("dulwich.cli", level="INFO") as cm:
  1477. result, stdout, stderr = self._run_cli("help")
  1478. log_output = "\n".join(cm.output)
  1479. self.assertIn("dulwich command line tool", log_output)
  1480. def test_help_all(self):
  1481. with self.assertLogs("dulwich.cli", level="INFO") as cm:
  1482. result, stdout, stderr = self._run_cli("help", "-a")
  1483. log_output = "\n".join(cm.output)
  1484. self.assertIn("Available commands:", log_output)
  1485. self.assertIn("add", log_output)
  1486. self.assertIn("commit", log_output)
  1487. class RemoteCommandTest(DulwichCliTestCase):
  1488. """Tests for remote commands."""
  1489. def test_remote_add(self):
  1490. result, stdout, stderr = self._run_cli(
  1491. "remote", "add", "origin", "https://github.com/example/repo.git"
  1492. )
  1493. # Check remote was added to config
  1494. config = self.repo.get_config()
  1495. self.assertEqual(
  1496. config.get((b"remote", b"origin"), b"url"),
  1497. b"https://github.com/example/repo.git",
  1498. )
  1499. class CheckIgnoreCommandTest(DulwichCliTestCase):
  1500. """Tests for check-ignore command."""
  1501. def test_check_ignore(self):
  1502. # Create .gitignore
  1503. gitignore = os.path.join(self.repo_path, ".gitignore")
  1504. with open(gitignore, "w") as f:
  1505. f.write("*.log\n")
  1506. with self.assertLogs("dulwich.cli", level="INFO") as cm:
  1507. result, stdout, stderr = self._run_cli(
  1508. "check-ignore", "test.log", "test.txt"
  1509. )
  1510. log_output = "\n".join(cm.output)
  1511. self.assertIn("test.log", log_output)
  1512. self.assertNotIn("test.txt", log_output)
  1513. class LsFilesCommandTest(DulwichCliTestCase):
  1514. """Tests for ls-files command."""
  1515. def test_ls_files(self):
  1516. # Add some files
  1517. for name in ["a.txt", "b.txt", "c.txt"]:
  1518. path = os.path.join(self.repo_path, name)
  1519. with open(path, "w") as f:
  1520. f.write(f"content of {name}")
  1521. self._run_cli("add", "a.txt", "b.txt", "c.txt")
  1522. with self.assertLogs("dulwich.cli", level="INFO") as cm:
  1523. result, stdout, stderr = self._run_cli("ls-files")
  1524. log_output = "\n".join(cm.output)
  1525. self.assertIn("a.txt", log_output)
  1526. self.assertIn("b.txt", log_output)
  1527. self.assertIn("c.txt", log_output)
  1528. class LsTreeCommandTest(DulwichCliTestCase):
  1529. """Tests for ls-tree command."""
  1530. def test_ls_tree(self):
  1531. # Create a directory structure
  1532. os.mkdir(os.path.join(self.repo_path, "subdir"))
  1533. with open(os.path.join(self.repo_path, "file.txt"), "w") as f:
  1534. f.write("file content")
  1535. with open(os.path.join(self.repo_path, "subdir", "nested.txt"), "w") as f:
  1536. f.write("nested content")
  1537. self._run_cli("add", ".")
  1538. self._run_cli("commit", "--message=Initial")
  1539. result, stdout, stderr = self._run_cli("ls-tree", "HEAD")
  1540. self.assertIn("file.txt", stdout)
  1541. self.assertIn("subdir", stdout)
  1542. def test_ls_tree_recursive(self):
  1543. # Create nested structure
  1544. os.mkdir(os.path.join(self.repo_path, "subdir"))
  1545. with open(os.path.join(self.repo_path, "subdir", "nested.txt"), "w") as f:
  1546. f.write("nested")
  1547. self._run_cli("add", ".")
  1548. self._run_cli("commit", "--message=Initial")
  1549. result, stdout, stderr = self._run_cli("ls-tree", "-r", "HEAD")
  1550. self.assertIn("subdir/nested.txt", stdout)
  1551. class DescribeCommandTest(DulwichCliTestCase):
  1552. """Tests for describe command."""
  1553. def test_describe(self):
  1554. # Create tagged commit
  1555. test_file = os.path.join(self.repo_path, "test.txt")
  1556. with open(test_file, "w") as f:
  1557. f.write("test")
  1558. self._run_cli("add", "test.txt")
  1559. self._run_cli("commit", "--message=Initial")
  1560. self._run_cli("tag", "v1.0")
  1561. with self.assertLogs("dulwich.cli", level="INFO") as cm:
  1562. result, stdout, stderr = self._run_cli("describe")
  1563. self.assertIn("v1.0", cm.output[0])
  1564. class FsckCommandTest(DulwichCliTestCase):
  1565. """Tests for fsck command."""
  1566. def test_fsck(self):
  1567. # Create a commit
  1568. test_file = os.path.join(self.repo_path, "test.txt")
  1569. with open(test_file, "w") as f:
  1570. f.write("test")
  1571. self._run_cli("add", "test.txt")
  1572. self._run_cli("commit", "--message=Initial")
  1573. result, stdout, stderr = self._run_cli("fsck")
  1574. # Should complete without errors
  1575. class RepackCommandTest(DulwichCliTestCase):
  1576. """Tests for repack command."""
  1577. def test_repack(self):
  1578. # Create some objects
  1579. for i in range(5):
  1580. test_file = os.path.join(self.repo_path, f"test{i}.txt")
  1581. with open(test_file, "w") as f:
  1582. f.write(f"content {i}")
  1583. self._run_cli("add", f"test{i}.txt")
  1584. self._run_cli("commit", f"--message=Commit {i}")
  1585. result, stdout, stderr = self._run_cli("repack")
  1586. # Should create pack files
  1587. pack_dir = os.path.join(self.repo_path, ".git", "objects", "pack")
  1588. self.assertTrue(any(f.endswith(".pack") for f in os.listdir(pack_dir)))
  1589. class ResetCommandTest(DulwichCliTestCase):
  1590. """Tests for reset command."""
  1591. def test_reset_soft(self):
  1592. # Create commits
  1593. test_file = os.path.join(self.repo_path, "test.txt")
  1594. with open(test_file, "w") as f:
  1595. f.write("first")
  1596. self._run_cli("add", "test.txt")
  1597. self._run_cli("commit", "--message=First")
  1598. first_commit = self.repo.head()
  1599. with open(test_file, "w") as f:
  1600. f.write("second")
  1601. self._run_cli("add", "test.txt")
  1602. self._run_cli("commit", "--message=Second")
  1603. # Reset soft
  1604. result, stdout, stderr = self._run_cli("reset", "--soft", first_commit.decode())
  1605. # HEAD should be at first commit
  1606. self.assertEqual(self.repo.head(), first_commit)
  1607. class WriteTreeCommandTest(DulwichCliTestCase):
  1608. """Tests for write-tree command."""
  1609. def test_write_tree(self):
  1610. # Create and add files
  1611. test_file = os.path.join(self.repo_path, "test.txt")
  1612. with open(test_file, "w") as f:
  1613. f.write("test")
  1614. self._run_cli("add", "test.txt")
  1615. result, stdout, stderr = self._run_cli("write-tree")
  1616. # Should output tree SHA
  1617. self.assertEqual(len(stdout.strip()), 40)
  1618. class UpdateServerInfoCommandTest(DulwichCliTestCase):
  1619. """Tests for update-server-info command."""
  1620. def test_update_server_info(self):
  1621. result, stdout, stderr = self._run_cli("update-server-info")
  1622. # Should create info/refs file
  1623. info_refs = os.path.join(self.repo_path, ".git", "info", "refs")
  1624. self.assertTrue(os.path.exists(info_refs))
  1625. class SymbolicRefCommandTest(DulwichCliTestCase):
  1626. """Tests for symbolic-ref command."""
  1627. def test_symbolic_ref(self):
  1628. # Create a branch
  1629. test_file = os.path.join(self.repo_path, "test.txt")
  1630. with open(test_file, "w") as f:
  1631. f.write("test")
  1632. self._run_cli("add", "test.txt")
  1633. self._run_cli("commit", "--message=Initial")
  1634. self._run_cli("branch", "test-branch")
  1635. result, stdout, stderr = self._run_cli(
  1636. "symbolic-ref", "HEAD", "refs/heads/test-branch"
  1637. )
  1638. # HEAD should now point to test-branch
  1639. self.assertEqual(
  1640. self.repo.refs.read_ref(b"HEAD"), b"ref: refs/heads/test-branch"
  1641. )
  1642. class BundleCommandTest(DulwichCliTestCase):
  1643. """Tests for bundle commands."""
  1644. def setUp(self):
  1645. super().setUp()
  1646. # Create a basic repository with some commits for bundle testing
  1647. # Create initial commit
  1648. test_file = os.path.join(self.repo_path, "file1.txt")
  1649. with open(test_file, "w") as f:
  1650. f.write("Content of file1\n")
  1651. self._run_cli("add", "file1.txt")
  1652. self._run_cli("commit", "--message=Initial commit")
  1653. # Create second commit
  1654. test_file2 = os.path.join(self.repo_path, "file2.txt")
  1655. with open(test_file2, "w") as f:
  1656. f.write("Content of file2\n")
  1657. self._run_cli("add", "file2.txt")
  1658. self._run_cli("commit", "--message=Add file2")
  1659. # Create a branch and tag for testing
  1660. self._run_cli("branch", "feature")
  1661. self._run_cli("tag", "v1.0")
  1662. def test_bundle_create_basic(self):
  1663. """Test basic bundle creation."""
  1664. bundle_file = os.path.join(self.test_dir, "test.bundle")
  1665. result, stdout, stderr = self._run_cli("bundle", "create", bundle_file, "HEAD")
  1666. self.assertEqual(result, 0)
  1667. self.assertTrue(os.path.exists(bundle_file))
  1668. self.assertGreater(os.path.getsize(bundle_file), 0)
  1669. def test_bundle_create_all_refs(self):
  1670. """Test bundle creation with --all flag."""
  1671. bundle_file = os.path.join(self.test_dir, "all.bundle")
  1672. result, stdout, stderr = self._run_cli("bundle", "create", "--all", bundle_file)
  1673. self.assertEqual(result, 0)
  1674. self.assertTrue(os.path.exists(bundle_file))
  1675. def test_bundle_create_specific_refs(self):
  1676. """Test bundle creation with specific refs."""
  1677. bundle_file = os.path.join(self.test_dir, "refs.bundle")
  1678. # Only use HEAD since feature branch may not exist
  1679. result, stdout, stderr = self._run_cli("bundle", "create", bundle_file, "HEAD")
  1680. self.assertEqual(result, 0)
  1681. self.assertTrue(os.path.exists(bundle_file))
  1682. def test_bundle_create_with_range(self):
  1683. """Test bundle creation with commit range."""
  1684. # Get the first commit SHA by looking at the log
  1685. result, stdout, stderr = self._run_cli("log", "--reverse")
  1686. lines = stdout.strip().split("\n")
  1687. # Find first commit line that contains a SHA
  1688. first_commit = None
  1689. for line in lines:
  1690. if line.startswith("commit "):
  1691. first_commit = line.split()[1][:8] # Get short SHA
  1692. break
  1693. if first_commit:
  1694. bundle_file = os.path.join(self.test_dir, "range.bundle")
  1695. result, stdout, stderr = self._run_cli(
  1696. "bundle", "create", bundle_file, f"{first_commit}..HEAD"
  1697. )
  1698. self.assertEqual(result, 0)
  1699. self.assertTrue(os.path.exists(bundle_file))
  1700. else:
  1701. self.skipTest("Could not determine first commit SHA")
  1702. def test_bundle_create_to_stdout(self):
  1703. """Test bundle creation to stdout."""
  1704. result, stdout, stderr = self._run_cli("bundle", "create", "-", "HEAD")
  1705. self.assertEqual(result, 0)
  1706. self.assertGreater(len(stdout), 0)
  1707. # Bundle output is binary, so check it's not empty
  1708. self.assertIsInstance(stdout, (str, bytes))
  1709. def test_bundle_create_no_refs(self):
  1710. """Test bundle creation with no refs specified."""
  1711. bundle_file = os.path.join(self.test_dir, "noref.bundle")
  1712. with self.assertLogs("dulwich.cli", level="ERROR") as cm:
  1713. result, stdout, stderr = self._run_cli("bundle", "create", bundle_file)
  1714. self.assertEqual(result, 1)
  1715. self.assertIn("No refs specified", cm.output[0])
  1716. def test_bundle_create_empty_bundle_refused(self):
  1717. """Test that empty bundles are refused."""
  1718. bundle_file = os.path.join(self.test_dir, "empty.bundle")
  1719. # Try to create bundle with non-existent ref - this should fail with KeyError
  1720. with self.assertRaises(KeyError):
  1721. result, stdout, stderr = self._run_cli(
  1722. "bundle", "create", bundle_file, "nonexistent-ref"
  1723. )
  1724. def test_bundle_verify_valid(self):
  1725. """Test bundle verification of valid bundle."""
  1726. bundle_file = os.path.join(self.test_dir, "valid.bundle")
  1727. # First create a bundle
  1728. result, stdout, stderr = self._run_cli("bundle", "create", bundle_file, "HEAD")
  1729. self.assertEqual(result, 0)
  1730. # Now verify it
  1731. with self.assertLogs("dulwich.cli", level="INFO") as cm:
  1732. result, stdout, stderr = self._run_cli("bundle", "verify", bundle_file)
  1733. self.assertEqual(result, 0)
  1734. self.assertIn("valid and can be applied", cm.output[0])
  1735. def test_bundle_verify_quiet(self):
  1736. """Test bundle verification with quiet flag."""
  1737. bundle_file = os.path.join(self.test_dir, "quiet.bundle")
  1738. # Create bundle
  1739. self._run_cli("bundle", "create", bundle_file, "HEAD")
  1740. # Verify quietly
  1741. result, stdout, stderr = self._run_cli(
  1742. "bundle", "verify", "--quiet", bundle_file
  1743. )
  1744. self.assertEqual(result, 0)
  1745. self.assertEqual(stdout.strip(), "")
  1746. def test_bundle_verify_from_stdin(self):
  1747. """Test bundle verification from stdin."""
  1748. bundle_file = os.path.join(self.test_dir, "stdin.bundle")
  1749. # Create bundle
  1750. self._run_cli("bundle", "create", bundle_file, "HEAD")
  1751. # Read bundle content
  1752. with open(bundle_file, "rb") as f:
  1753. bundle_content = f.read()
  1754. # Mock stdin with bundle content
  1755. old_stdin = sys.stdin
  1756. try:
  1757. sys.stdin = io.BytesIO(bundle_content)
  1758. sys.stdin.buffer = sys.stdin
  1759. result, stdout, stderr = self._run_cli("bundle", "verify", "-")
  1760. self.assertEqual(result, 0)
  1761. finally:
  1762. sys.stdin = old_stdin
  1763. def test_bundle_list_heads(self):
  1764. """Test listing bundle heads."""
  1765. bundle_file = os.path.join(self.test_dir, "heads.bundle")
  1766. # Create bundle with HEAD only
  1767. self._run_cli("bundle", "create", bundle_file, "HEAD")
  1768. # List heads
  1769. with self.assertLogs("dulwich.cli", level="INFO") as cm:
  1770. result, stdout, stderr = self._run_cli("bundle", "list-heads", bundle_file)
  1771. self.assertEqual(result, 0)
  1772. # Should contain at least the HEAD reference
  1773. self.assertTrue(len(cm.output) > 0)
  1774. def test_bundle_list_heads_specific_refs(self):
  1775. """Test listing specific bundle heads."""
  1776. bundle_file = os.path.join(self.test_dir, "specific.bundle")
  1777. # Create bundle with HEAD
  1778. self._run_cli("bundle", "create", bundle_file, "HEAD")
  1779. # List heads without filtering
  1780. with self.assertLogs("dulwich.cli", level="INFO") as cm:
  1781. result, stdout, stderr = self._run_cli("bundle", "list-heads", bundle_file)
  1782. self.assertEqual(result, 0)
  1783. # Should contain some reference
  1784. self.assertTrue(len(cm.output) > 0)
  1785. def test_bundle_list_heads_from_stdin(self):
  1786. """Test listing bundle heads from stdin."""
  1787. bundle_file = os.path.join(self.test_dir, "stdin-heads.bundle")
  1788. # Create bundle
  1789. self._run_cli("bundle", "create", bundle_file, "HEAD")
  1790. # Read bundle content
  1791. with open(bundle_file, "rb") as f:
  1792. bundle_content = f.read()
  1793. # Mock stdin
  1794. old_stdin = sys.stdin
  1795. try:
  1796. sys.stdin = io.BytesIO(bundle_content)
  1797. sys.stdin.buffer = sys.stdin
  1798. result, stdout, stderr = self._run_cli("bundle", "list-heads", "-")
  1799. self.assertEqual(result, 0)
  1800. finally:
  1801. sys.stdin = old_stdin
  1802. def test_bundle_unbundle(self):
  1803. """Test bundle unbundling."""
  1804. bundle_file = os.path.join(self.test_dir, "unbundle.bundle")
  1805. # Create bundle
  1806. self._run_cli("bundle", "create", bundle_file, "HEAD")
  1807. # Unbundle
  1808. result, stdout, stderr = self._run_cli("bundle", "unbundle", bundle_file)
  1809. self.assertEqual(result, 0)
  1810. def test_bundle_unbundle_specific_refs(self):
  1811. """Test unbundling specific refs."""
  1812. bundle_file = os.path.join(self.test_dir, "unbundle-specific.bundle")
  1813. # Create bundle with HEAD
  1814. self._run_cli("bundle", "create", bundle_file, "HEAD")
  1815. # Unbundle only HEAD
  1816. result, stdout, stderr = self._run_cli(
  1817. "bundle", "unbundle", bundle_file, "HEAD"
  1818. )
  1819. self.assertEqual(result, 0)
  1820. def test_bundle_unbundle_from_stdin(self):
  1821. """Test unbundling from stdin."""
  1822. bundle_file = os.path.join(self.test_dir, "stdin-unbundle.bundle")
  1823. # Create bundle
  1824. self._run_cli("bundle", "create", bundle_file, "HEAD")
  1825. # Read bundle content to simulate stdin
  1826. with open(bundle_file, "rb") as f:
  1827. bundle_content = f.read()
  1828. # Mock stdin with bundle content
  1829. old_stdin = sys.stdin
  1830. try:
  1831. # Create a BytesIO object with buffer attribute
  1832. mock_stdin = io.BytesIO(bundle_content)
  1833. mock_stdin.buffer = mock_stdin
  1834. sys.stdin = mock_stdin
  1835. result, stdout, stderr = self._run_cli("bundle", "unbundle", "-")
  1836. self.assertEqual(result, 0)
  1837. finally:
  1838. sys.stdin = old_stdin
  1839. def test_bundle_unbundle_with_progress(self):
  1840. """Test unbundling with progress output."""
  1841. bundle_file = os.path.join(self.test_dir, "progress.bundle")
  1842. # Create bundle
  1843. self._run_cli("bundle", "create", bundle_file, "HEAD")
  1844. # Unbundle with progress
  1845. result, stdout, stderr = self._run_cli(
  1846. "bundle", "unbundle", "--progress", bundle_file
  1847. )
  1848. self.assertEqual(result, 0)
  1849. def test_bundle_create_with_progress(self):
  1850. """Test bundle creation with progress output."""
  1851. bundle_file = os.path.join(self.test_dir, "create-progress.bundle")
  1852. result, stdout, stderr = self._run_cli(
  1853. "bundle", "create", "--progress", bundle_file, "HEAD"
  1854. )
  1855. self.assertEqual(result, 0)
  1856. self.assertTrue(os.path.exists(bundle_file))
  1857. def test_bundle_create_with_quiet(self):
  1858. """Test bundle creation with quiet flag."""
  1859. bundle_file = os.path.join(self.test_dir, "quiet-create.bundle")
  1860. result, stdout, stderr = self._run_cli(
  1861. "bundle", "create", "--quiet", bundle_file, "HEAD"
  1862. )
  1863. self.assertEqual(result, 0)
  1864. self.assertTrue(os.path.exists(bundle_file))
  1865. def test_bundle_create_version_2(self):
  1866. """Test bundle creation with specific version."""
  1867. bundle_file = os.path.join(self.test_dir, "v2.bundle")
  1868. result, stdout, stderr = self._run_cli(
  1869. "bundle", "create", "--version", "2", bundle_file, "HEAD"
  1870. )
  1871. self.assertEqual(result, 0)
  1872. self.assertTrue(os.path.exists(bundle_file))
  1873. def test_bundle_create_version_3(self):
  1874. """Test bundle creation with version 3."""
  1875. bundle_file = os.path.join(self.test_dir, "v3.bundle")
  1876. result, stdout, stderr = self._run_cli(
  1877. "bundle", "create", "--version", "3", bundle_file, "HEAD"
  1878. )
  1879. self.assertEqual(result, 0)
  1880. self.assertTrue(os.path.exists(bundle_file))
  1881. def test_bundle_invalid_subcommand(self):
  1882. """Test invalid bundle subcommand."""
  1883. with self.assertLogs("dulwich.cli", level="ERROR") as cm:
  1884. result, stdout, stderr = self._run_cli("bundle", "invalid-command")
  1885. self.assertEqual(result, 1)
  1886. self.assertIn("Unknown bundle subcommand", cm.output[0])
  1887. def test_bundle_no_subcommand(self):
  1888. """Test bundle command with no subcommand."""
  1889. with self.assertLogs("dulwich.cli", level="ERROR") as cm:
  1890. result, stdout, stderr = self._run_cli("bundle")
  1891. self.assertEqual(result, 1)
  1892. self.assertIn("Usage: bundle", cm.output[0])
  1893. def test_bundle_create_with_stdin_refs(self):
  1894. """Test bundle creation reading refs from stdin."""
  1895. bundle_file = os.path.join(self.test_dir, "stdin-refs.bundle")
  1896. # Mock stdin with refs
  1897. old_stdin = sys.stdin
  1898. try:
  1899. sys.stdin = io.StringIO("master\nfeature\n")
  1900. result, stdout, stderr = self._run_cli(
  1901. "bundle", "create", "--stdin", bundle_file
  1902. )
  1903. self.assertEqual(result, 0)
  1904. self.assertTrue(os.path.exists(bundle_file))
  1905. finally:
  1906. sys.stdin = old_stdin
  1907. def test_bundle_verify_missing_prerequisites(self):
  1908. """Test bundle verification with missing prerequisites."""
  1909. # Create a simple bundle first
  1910. bundle_file = os.path.join(self.test_dir, "prereq.bundle")
  1911. self._run_cli("bundle", "create", bundle_file, "HEAD")
  1912. # Create a new repo to simulate missing objects
  1913. new_repo_path = os.path.join(self.test_dir, "new_repo")
  1914. os.mkdir(new_repo_path)
  1915. new_repo = Repo.init(new_repo_path)
  1916. new_repo.close()
  1917. # Try to verify in new repo
  1918. old_cwd = os.getcwd()
  1919. try:
  1920. os.chdir(new_repo_path)
  1921. result, stdout, stderr = self._run_cli("bundle", "verify", bundle_file)
  1922. # Just check that verification runs - result depends on bundle content
  1923. self.assertIn(result, [0, 1])
  1924. finally:
  1925. os.chdir(old_cwd)
  1926. def test_bundle_create_with_committish_range(self):
  1927. """Test bundle creation with commit range using parse_committish_range."""
  1928. # Create additional commits for range testing
  1929. test_file3 = os.path.join(self.repo_path, "file3.txt")
  1930. with open(test_file3, "w") as f:
  1931. f.write("Content of file3\n")
  1932. self._run_cli("add", "file3.txt")
  1933. self._run_cli("commit", "--message=Add file3")
  1934. # Get commit SHAs
  1935. result, stdout, stderr = self._run_cli("log")
  1936. lines = stdout.strip().split("\n")
  1937. # Extract SHAs from commit lines
  1938. commits = []
  1939. for line in lines:
  1940. if line.startswith("commit:"):
  1941. sha = line.split()[1]
  1942. commits.append(sha[:8]) # Get short SHA
  1943. # We should have exactly 3 commits: Add file3, Add file2, Initial commit
  1944. self.assertEqual(len(commits), 3)
  1945. bundle_file = os.path.join(self.test_dir, "range-test.bundle")
  1946. # Test with commit range using .. syntax
  1947. # Create a bundle containing commits reachable from commits[0] but not from commits[2]
  1948. result, stdout, stderr = self._run_cli(
  1949. "bundle", "create", bundle_file, f"{commits[2]}..HEAD"
  1950. )
  1951. if result != 0:
  1952. self.fail(
  1953. f"Bundle create failed with exit code {result}. stdout: {stdout!r}, stderr: {stderr!r}"
  1954. )
  1955. self.assertEqual(result, 0)
  1956. self.assertTrue(os.path.exists(bundle_file))
  1957. # Verify the bundle was created
  1958. with self.assertLogs("dulwich.cli", level="INFO") as cm:
  1959. result, stdout, stderr = self._run_cli("bundle", "verify", bundle_file)
  1960. self.assertEqual(result, 0)
  1961. self.assertIn("valid and can be applied", cm.output[0])
  1962. class FormatBytesTestCase(TestCase):
  1963. """Tests for format_bytes function."""
  1964. def test_bytes(self):
  1965. """Test formatting bytes."""
  1966. self.assertEqual("0.0 B", format_bytes(0))
  1967. self.assertEqual("1.0 B", format_bytes(1))
  1968. self.assertEqual("512.0 B", format_bytes(512))
  1969. self.assertEqual("1023.0 B", format_bytes(1023))
  1970. def test_kilobytes(self):
  1971. """Test formatting kilobytes."""
  1972. self.assertEqual("1.0 KB", format_bytes(1024))
  1973. self.assertEqual("1.5 KB", format_bytes(1536))
  1974. self.assertEqual("2.0 KB", format_bytes(2048))
  1975. self.assertEqual("1023.0 KB", format_bytes(1024 * 1023))
  1976. def test_megabytes(self):
  1977. """Test formatting megabytes."""
  1978. self.assertEqual("1.0 MB", format_bytes(1024 * 1024))
  1979. self.assertEqual("1.5 MB", format_bytes(1024 * 1024 * 1.5))
  1980. self.assertEqual("10.0 MB", format_bytes(1024 * 1024 * 10))
  1981. self.assertEqual("1023.0 MB", format_bytes(1024 * 1024 * 1023))
  1982. def test_gigabytes(self):
  1983. """Test formatting gigabytes."""
  1984. self.assertEqual("1.0 GB", format_bytes(1024 * 1024 * 1024))
  1985. self.assertEqual("2.5 GB", format_bytes(1024 * 1024 * 1024 * 2.5))
  1986. self.assertEqual("1023.0 GB", format_bytes(1024 * 1024 * 1024 * 1023))
  1987. def test_terabytes(self):
  1988. """Test formatting terabytes."""
  1989. self.assertEqual("1.0 TB", format_bytes(1024 * 1024 * 1024 * 1024))
  1990. self.assertEqual("5.0 TB", format_bytes(1024 * 1024 * 1024 * 1024 * 5))
  1991. self.assertEqual("1000.0 TB", format_bytes(1024 * 1024 * 1024 * 1024 * 1000))
  1992. class ParseRelativeTimeTestCase(TestCase):
  1993. """Tests for parse_relative_time function."""
  1994. def test_now(self):
  1995. """Test parsing 'now'."""
  1996. self.assertEqual(0, parse_relative_time("now"))
  1997. def test_seconds(self):
  1998. """Test parsing seconds."""
  1999. self.assertEqual(1, parse_relative_time("1 second ago"))
  2000. self.assertEqual(5, parse_relative_time("5 seconds ago"))
  2001. self.assertEqual(30, parse_relative_time("30 seconds ago"))
  2002. def test_minutes(self):
  2003. """Test parsing minutes."""
  2004. self.assertEqual(60, parse_relative_time("1 minute ago"))
  2005. self.assertEqual(300, parse_relative_time("5 minutes ago"))
  2006. self.assertEqual(1800, parse_relative_time("30 minutes ago"))
  2007. def test_hours(self):
  2008. """Test parsing hours."""
  2009. self.assertEqual(3600, parse_relative_time("1 hour ago"))
  2010. self.assertEqual(7200, parse_relative_time("2 hours ago"))
  2011. self.assertEqual(86400, parse_relative_time("24 hours ago"))
  2012. def test_days(self):
  2013. """Test parsing days."""
  2014. self.assertEqual(86400, parse_relative_time("1 day ago"))
  2015. self.assertEqual(604800, parse_relative_time("7 days ago"))
  2016. self.assertEqual(2592000, parse_relative_time("30 days ago"))
  2017. def test_weeks(self):
  2018. """Test parsing weeks."""
  2019. self.assertEqual(604800, parse_relative_time("1 week ago"))
  2020. self.assertEqual(1209600, parse_relative_time("2 weeks ago"))
  2021. self.assertEqual(
  2022. 36288000, parse_relative_time("60 weeks ago")
  2023. ) # 60 * 7 * 24 * 60 * 60
  2024. def test_invalid_format(self):
  2025. """Test invalid time formats."""
  2026. with self.assertRaises(ValueError) as cm:
  2027. parse_relative_time("invalid")
  2028. self.assertIn("Invalid relative time format", str(cm.exception))
  2029. with self.assertRaises(ValueError) as cm:
  2030. parse_relative_time("2 weeks")
  2031. self.assertIn("Invalid relative time format", str(cm.exception))
  2032. with self.assertRaises(ValueError) as cm:
  2033. parse_relative_time("ago")
  2034. self.assertIn("Invalid relative time format", str(cm.exception))
  2035. with self.assertRaises(ValueError) as cm:
  2036. parse_relative_time("two weeks ago")
  2037. self.assertIn("Invalid number in relative time", str(cm.exception))
  2038. def test_invalid_unit(self):
  2039. """Test invalid time units."""
  2040. with self.assertRaises(ValueError) as cm:
  2041. parse_relative_time("5 months ago")
  2042. self.assertIn("Unknown time unit: months", str(cm.exception))
  2043. with self.assertRaises(ValueError) as cm:
  2044. parse_relative_time("2 years ago")
  2045. self.assertIn("Unknown time unit: years", str(cm.exception))
  2046. def test_singular_plural(self):
  2047. """Test that both singular and plural forms work."""
  2048. self.assertEqual(
  2049. parse_relative_time("1 second ago"), parse_relative_time("1 seconds ago")
  2050. )
  2051. self.assertEqual(
  2052. parse_relative_time("1 minute ago"), parse_relative_time("1 minutes ago")
  2053. )
  2054. self.assertEqual(
  2055. parse_relative_time("1 hour ago"), parse_relative_time("1 hours ago")
  2056. )
  2057. self.assertEqual(
  2058. parse_relative_time("1 day ago"), parse_relative_time("1 days ago")
  2059. )
  2060. self.assertEqual(
  2061. parse_relative_time("1 week ago"), parse_relative_time("1 weeks ago")
  2062. )
  2063. class GetPagerTest(TestCase):
  2064. """Tests for get_pager function."""
  2065. def setUp(self):
  2066. super().setUp()
  2067. # Save original environment
  2068. self.original_env = os.environ.copy()
  2069. # Clear pager-related environment variables
  2070. for var in ["DULWICH_PAGER", "GIT_PAGER", "PAGER"]:
  2071. os.environ.pop(var, None)
  2072. # Reset the global pager disable flag
  2073. cli.get_pager._disabled = False
  2074. def tearDown(self):
  2075. super().tearDown()
  2076. # Restore original environment
  2077. os.environ.clear()
  2078. os.environ.update(self.original_env)
  2079. # Reset the global pager disable flag
  2080. cli.get_pager._disabled = False
  2081. def test_pager_disabled_globally(self):
  2082. """Test that globally disabled pager returns stdout wrapper."""
  2083. cli.disable_pager()
  2084. pager = cli.get_pager()
  2085. self.assertIsInstance(pager, cli._StreamContextAdapter)
  2086. self.assertEqual(pager.stream, sys.stdout)
  2087. def test_pager_not_tty(self):
  2088. """Test that pager is disabled when stdout is not a TTY."""
  2089. with patch("sys.stdout.isatty", return_value=False):
  2090. pager = cli.get_pager()
  2091. self.assertIsInstance(pager, cli._StreamContextAdapter)
  2092. def test_pager_env_dulwich_pager(self):
  2093. """Test DULWICH_PAGER environment variable."""
  2094. os.environ["DULWICH_PAGER"] = "custom_pager"
  2095. with patch("sys.stdout.isatty", return_value=True):
  2096. pager = cli.get_pager()
  2097. self.assertIsInstance(pager, cli.Pager)
  2098. self.assertEqual(pager.pager_cmd, "custom_pager")
  2099. def test_pager_env_dulwich_pager_false(self):
  2100. """Test DULWICH_PAGER=false disables pager."""
  2101. os.environ["DULWICH_PAGER"] = "false"
  2102. with patch("sys.stdout.isatty", return_value=True):
  2103. pager = cli.get_pager()
  2104. self.assertIsInstance(pager, cli._StreamContextAdapter)
  2105. def test_pager_env_git_pager(self):
  2106. """Test GIT_PAGER environment variable."""
  2107. os.environ["GIT_PAGER"] = "git_custom_pager"
  2108. with patch("sys.stdout.isatty", return_value=True):
  2109. pager = cli.get_pager()
  2110. self.assertIsInstance(pager, cli.Pager)
  2111. self.assertEqual(pager.pager_cmd, "git_custom_pager")
  2112. def test_pager_env_pager(self):
  2113. """Test PAGER environment variable."""
  2114. os.environ["PAGER"] = "my_pager"
  2115. with patch("sys.stdout.isatty", return_value=True):
  2116. pager = cli.get_pager()
  2117. self.assertIsInstance(pager, cli.Pager)
  2118. self.assertEqual(pager.pager_cmd, "my_pager")
  2119. def test_pager_env_priority(self):
  2120. """Test environment variable priority order."""
  2121. os.environ["PAGER"] = "pager_low"
  2122. os.environ["GIT_PAGER"] = "pager_medium"
  2123. os.environ["DULWICH_PAGER"] = "pager_high"
  2124. with patch("sys.stdout.isatty", return_value=True):
  2125. pager = cli.get_pager()
  2126. self.assertEqual(pager.pager_cmd, "pager_high")
  2127. def test_pager_config_core_pager(self):
  2128. """Test core.pager configuration."""
  2129. config = MagicMock()
  2130. config.get.return_value = b"config_pager"
  2131. with patch("sys.stdout.isatty", return_value=True):
  2132. pager = cli.get_pager(config=config)
  2133. self.assertIsInstance(pager, cli.Pager)
  2134. self.assertEqual(pager.pager_cmd, "config_pager")
  2135. config.get.assert_called_with(("core",), b"pager")
  2136. def test_pager_config_core_pager_false(self):
  2137. """Test core.pager=false disables pager."""
  2138. config = MagicMock()
  2139. config.get.return_value = b"false"
  2140. with patch("sys.stdout.isatty", return_value=True):
  2141. pager = cli.get_pager(config=config)
  2142. self.assertIsInstance(pager, cli._StreamContextAdapter)
  2143. def test_pager_config_core_pager_empty(self):
  2144. """Test core.pager="" disables pager."""
  2145. config = MagicMock()
  2146. config.get.return_value = b""
  2147. with patch("sys.stdout.isatty", return_value=True):
  2148. pager = cli.get_pager(config=config)
  2149. self.assertIsInstance(pager, cli._StreamContextAdapter)
  2150. def test_pager_config_per_command(self):
  2151. """Test per-command pager configuration."""
  2152. config = MagicMock()
  2153. config.get.side_effect = lambda section, key: {
  2154. (("pager",), b"log"): b"log_pager",
  2155. }.get((section, key), KeyError())
  2156. with patch("sys.stdout.isatty", return_value=True):
  2157. pager = cli.get_pager(config=config, cmd_name="log")
  2158. self.assertIsInstance(pager, cli.Pager)
  2159. self.assertEqual(pager.pager_cmd, "log_pager")
  2160. def test_pager_config_per_command_false(self):
  2161. """Test per-command pager=false disables pager."""
  2162. config = MagicMock()
  2163. config.get.return_value = b"false"
  2164. with patch("sys.stdout.isatty", return_value=True):
  2165. pager = cli.get_pager(config=config, cmd_name="log")
  2166. self.assertIsInstance(pager, cli._StreamContextAdapter)
  2167. def test_pager_config_per_command_true(self):
  2168. """Test per-command pager=true uses default pager."""
  2169. config = MagicMock()
  2170. def get_side_effect(section, key):
  2171. if section == ("pager",) and key == b"log":
  2172. return b"true"
  2173. raise KeyError
  2174. config.get.side_effect = get_side_effect
  2175. with patch("sys.stdout.isatty", return_value=True):
  2176. with patch("shutil.which", side_effect=lambda cmd: cmd == "less"):
  2177. pager = cli.get_pager(config=config, cmd_name="log")
  2178. self.assertIsInstance(pager, cli.Pager)
  2179. self.assertEqual(pager.pager_cmd, "less -FRX")
  2180. def test_pager_priority_order(self):
  2181. """Test complete priority order."""
  2182. # Set up all possible configurations
  2183. os.environ["PAGER"] = "env_pager"
  2184. os.environ["GIT_PAGER"] = "env_git_pager"
  2185. config = MagicMock()
  2186. def get_side_effect(section, key):
  2187. if section == ("pager",) and key == b"log":
  2188. return b"cmd_pager"
  2189. elif section == ("core",) and key == b"pager":
  2190. return b"core_pager"
  2191. raise KeyError
  2192. config.get.side_effect = get_side_effect
  2193. with patch("sys.stdout.isatty", return_value=True):
  2194. # Per-command config should win
  2195. pager = cli.get_pager(config=config, cmd_name="log")
  2196. self.assertEqual(pager.pager_cmd, "cmd_pager")
  2197. def test_pager_fallback_less(self):
  2198. """Test fallback to less with proper flags."""
  2199. with patch("sys.stdout.isatty", return_value=True):
  2200. with patch("shutil.which", side_effect=lambda cmd: cmd == "less"):
  2201. pager = cli.get_pager()
  2202. self.assertIsInstance(pager, cli.Pager)
  2203. self.assertEqual(pager.pager_cmd, "less -FRX")
  2204. def test_pager_fallback_more(self):
  2205. """Test fallback to more when less is not available."""
  2206. with patch("sys.stdout.isatty", return_value=True):
  2207. with patch("shutil.which", side_effect=lambda cmd: cmd == "more"):
  2208. pager = cli.get_pager()
  2209. self.assertIsInstance(pager, cli.Pager)
  2210. self.assertEqual(pager.pager_cmd, "more")
  2211. def test_pager_fallback_cat(self):
  2212. """Test ultimate fallback to cat."""
  2213. with patch("sys.stdout.isatty", return_value=True):
  2214. with patch("shutil.which", return_value=None):
  2215. pager = cli.get_pager()
  2216. self.assertIsInstance(pager, cli.Pager)
  2217. self.assertEqual(pager.pager_cmd, "cat")
  2218. def test_pager_context_manager(self):
  2219. """Test that pager works as a context manager."""
  2220. with patch("sys.stdout.isatty", return_value=True):
  2221. with cli.get_pager() as pager:
  2222. self.assertTrue(hasattr(pager, "write"))
  2223. self.assertTrue(hasattr(pager, "flush"))
  2224. class WorktreeCliTests(DulwichCliTestCase):
  2225. """Tests for worktree CLI commands."""
  2226. def setUp(self):
  2227. super().setUp()
  2228. # Base class already creates and initializes the repo
  2229. # Just create initial commit
  2230. with open(os.path.join(self.repo_path, "test.txt"), "w") as f:
  2231. f.write("test content")
  2232. from dulwich import porcelain
  2233. porcelain.add(self.repo_path, ["test.txt"])
  2234. porcelain.commit(self.repo_path, message=b"Initial commit")
  2235. def test_worktree_list(self):
  2236. """Test worktree list command."""
  2237. # Change to repo directory
  2238. old_cwd = os.getcwd()
  2239. os.chdir(self.repo_path)
  2240. try:
  2241. io.StringIO()
  2242. cmd = cli.cmd_worktree()
  2243. result = cmd.run(["list"])
  2244. # Should list the main worktree
  2245. self.assertEqual(result, 0)
  2246. finally:
  2247. os.chdir(old_cwd)
  2248. def test_worktree_add(self):
  2249. """Test worktree add command."""
  2250. wt_path = os.path.join(self.test_dir, "worktree1")
  2251. with self.assertLogs("dulwich.cli", level="INFO") as cm:
  2252. result, stdout, stderr = self._run_cli(
  2253. "worktree", "add", wt_path, "feature"
  2254. )
  2255. self.assertEqual(result, 0)
  2256. self.assertTrue(os.path.exists(wt_path))
  2257. log_output = "\n".join(cm.output)
  2258. self.assertIn("Worktree added:", log_output)
  2259. def test_worktree_add_detached(self):
  2260. """Test worktree add with detached HEAD."""
  2261. wt_path = os.path.join(self.test_dir, "detached-wt")
  2262. # Change to repo directory
  2263. old_cwd = os.getcwd()
  2264. os.chdir(self.repo_path)
  2265. try:
  2266. cmd = cli.cmd_worktree()
  2267. with patch("sys.stdout", new_callable=io.StringIO):
  2268. result = cmd.run(["add", "--detach", wt_path])
  2269. self.assertEqual(result, 0)
  2270. self.assertTrue(os.path.exists(wt_path))
  2271. finally:
  2272. os.chdir(old_cwd)
  2273. def test_worktree_remove(self):
  2274. """Test worktree remove command."""
  2275. # First add a worktree
  2276. wt_path = os.path.join(self.test_dir, "to-remove")
  2277. result, stdout, stderr = self._run_cli("worktree", "add", wt_path)
  2278. self.assertEqual(result, 0)
  2279. # Then remove it
  2280. with self.assertLogs("dulwich.cli", level="INFO") as cm:
  2281. result, stdout, stderr = self._run_cli("worktree", "remove", wt_path)
  2282. self.assertEqual(result, 0)
  2283. self.assertFalse(os.path.exists(wt_path))
  2284. log_output = "\n".join(cm.output)
  2285. self.assertIn("Worktree removed:", log_output)
  2286. def test_worktree_prune(self):
  2287. """Test worktree prune command."""
  2288. # Add a worktree and manually remove it
  2289. wt_path = os.path.join(self.test_dir, "to-prune")
  2290. result, stdout, stderr = self._run_cli("worktree", "add", wt_path)
  2291. self.assertEqual(result, 0)
  2292. shutil.rmtree(wt_path)
  2293. # Prune
  2294. with self.assertLogs("dulwich.cli", level="INFO") as cm:
  2295. result, stdout, stderr = self._run_cli("worktree", "prune", "-v")
  2296. self.assertEqual(result, 0)
  2297. log_output = "\n".join(cm.output)
  2298. self.assertIn("to-prune", log_output)
  2299. def test_worktree_lock_unlock(self):
  2300. """Test worktree lock and unlock commands."""
  2301. # Add a worktree
  2302. wt_path = os.path.join(self.test_dir, "lockable")
  2303. result, stdout, stderr = self._run_cli("worktree", "add", wt_path)
  2304. self.assertEqual(result, 0)
  2305. # Lock it
  2306. with self.assertLogs("dulwich.cli", level="INFO") as cm:
  2307. result, stdout, stderr = self._run_cli(
  2308. "worktree", "lock", wt_path, "--reason", "Testing"
  2309. )
  2310. self.assertEqual(result, 0)
  2311. log_output = "\n".join(cm.output)
  2312. self.assertIn("Worktree locked:", log_output)
  2313. # Unlock it
  2314. with self.assertLogs("dulwich.cli", level="INFO") as cm:
  2315. result, stdout, stderr = self._run_cli("worktree", "unlock", wt_path)
  2316. self.assertEqual(result, 0)
  2317. log_output = "\n".join(cm.output)
  2318. self.assertIn("Worktree unlocked:", log_output)
  2319. def test_worktree_move(self):
  2320. """Test worktree move command."""
  2321. # Add a worktree
  2322. old_path = os.path.join(self.test_dir, "old-location")
  2323. new_path = os.path.join(self.test_dir, "new-location")
  2324. result, stdout, stderr = self._run_cli("worktree", "add", old_path)
  2325. self.assertEqual(result, 0)
  2326. # Move it
  2327. with self.assertLogs("dulwich.cli", level="INFO") as cm:
  2328. result, stdout, stderr = self._run_cli(
  2329. "worktree", "move", old_path, new_path
  2330. )
  2331. self.assertEqual(result, 0)
  2332. self.assertFalse(os.path.exists(old_path))
  2333. self.assertTrue(os.path.exists(new_path))
  2334. log_output = "\n".join(cm.output)
  2335. self.assertIn("Worktree moved:", log_output)
  2336. def test_worktree_invalid_command(self):
  2337. """Test invalid worktree subcommand."""
  2338. cmd = cli.cmd_worktree()
  2339. with patch("sys.stderr", new_callable=io.StringIO):
  2340. with self.assertRaises(SystemExit):
  2341. cmd.run(["invalid"])
  2342. if __name__ == "__main__":
  2343. unittest.main()