cli.py 131 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977297829792980298129822983298429852986298729882989299029912992299329942995299629972998299930003001300230033004300530063007300830093010301130123013301430153016301730183019302030213022302330243025302630273028302930303031303230333034303530363037303830393040304130423043304430453046304730483049305030513052305330543055305630573058305930603061306230633064306530663067306830693070307130723073307430753076307730783079308030813082308330843085308630873088308930903091309230933094309530963097309830993100310131023103310431053106310731083109311031113112311331143115311631173118311931203121312231233124312531263127312831293130313131323133313431353136313731383139314031413142314331443145314631473148314931503151315231533154315531563157315831593160316131623163316431653166316731683169317031713172317331743175317631773178317931803181318231833184318531863187318831893190319131923193319431953196319731983199320032013202320332043205320632073208320932103211321232133214321532163217321832193220322132223223322432253226322732283229323032313232323332343235323632373238323932403241324232433244324532463247324832493250325132523253325432553256325732583259326032613262326332643265326632673268326932703271327232733274327532763277327832793280328132823283328432853286328732883289329032913292329332943295329632973298329933003301330233033304330533063307330833093310331133123313331433153316331733183319332033213322332333243325332633273328332933303331333233333334333533363337333833393340334133423343334433453346334733483349335033513352335333543355335633573358335933603361336233633364336533663367336833693370337133723373337433753376337733783379338033813382338333843385338633873388338933903391339233933394339533963397339833993400340134023403340434053406340734083409341034113412341334143415341634173418341934203421342234233424342534263427342834293430343134323433343434353436343734383439344034413442344334443445344634473448344934503451345234533454345534563457345834593460346134623463346434653466346734683469347034713472347334743475347634773478347934803481348234833484348534863487348834893490349134923493349434953496349734983499350035013502350335043505350635073508350935103511351235133514351535163517351835193520352135223523352435253526352735283529353035313532353335343535353635373538353935403541354235433544354535463547354835493550355135523553355435553556355735583559356035613562356335643565356635673568356935703571357235733574357535763577357835793580358135823583358435853586358735883589359035913592359335943595359635973598359936003601360236033604360536063607360836093610361136123613361436153616361736183619362036213622362336243625362636273628362936303631363236333634363536363637363836393640364136423643364436453646364736483649365036513652365336543655365636573658365936603661366236633664366536663667366836693670367136723673367436753676367736783679368036813682368336843685368636873688368936903691369236933694369536963697369836993700370137023703370437053706370737083709371037113712371337143715371637173718371937203721372237233724372537263727372837293730373137323733373437353736373737383739374037413742374337443745374637473748374937503751375237533754375537563757375837593760376137623763376437653766376737683769377037713772377337743775377637773778377937803781378237833784378537863787378837893790379137923793379437953796379737983799380038013802380338043805380638073808380938103811381238133814381538163817381838193820382138223823382438253826382738283829383038313832383338343835383638373838383938403841384238433844384538463847384838493850385138523853385438553856385738583859386038613862386338643865386638673868386938703871387238733874387538763877387838793880388138823883388438853886388738883889389038913892389338943895389638973898389939003901390239033904390539063907390839093910391139123913391439153916391739183919392039213922392339243925392639273928392939303931393239333934393539363937393839393940394139423943394439453946394739483949395039513952395339543955395639573958395939603961396239633964396539663967396839693970397139723973397439753976397739783979398039813982398339843985398639873988398939903991399239933994399539963997399839994000400140024003400440054006400740084009401040114012401340144015401640174018401940204021402240234024402540264027402840294030403140324033403440354036403740384039404040414042404340444045404640474048404940504051405240534054405540564057405840594060406140624063406440654066406740684069407040714072407340744075407640774078407940804081408240834084408540864087408840894090409140924093409440954096409740984099410041014102
  1. #
  2. # dulwich - Simple command-line interface to Dulwich
  3. # Copyright (C) 2008-2011 Jelmer Vernooij <jelmer@jelmer.uk>
  4. # vim: expandtab
  5. #
  6. # SPDX-License-Identifier: Apache-2.0 OR GPL-2.0-or-later
  7. # Dulwich is dual-licensed under the Apache License, Version 2.0 and the GNU
  8. # General Public License as published by the Free Software Foundation; version 2.0
  9. # or (at your option) any later version. You can redistribute it and/or
  10. # modify it under the terms of either of these two licenses.
  11. #
  12. # Unless required by applicable law or agreed to in writing, software
  13. # distributed under the License is distributed on an "AS IS" BASIS,
  14. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  15. # See the License for the specific language governing permissions and
  16. # limitations under the License.
  17. #
  18. # You should have received a copy of the licenses; if not, see
  19. # <http://www.gnu.org/licenses/> for a copy of the GNU General Public License
  20. # and <http://www.apache.org/licenses/LICENSE-2.0> for a copy of the Apache
  21. # License, Version 2.0.
  22. #
  23. """Simple command-line interface to Dulwich>.
  24. This is a very simple command-line wrapper for Dulwich. It is by
  25. no means intended to be a full-blown Git command-line interface but just
  26. a way to test Dulwich.
  27. """
  28. import argparse
  29. import logging
  30. import os
  31. import shutil
  32. import signal
  33. import subprocess
  34. import sys
  35. import tempfile
  36. from pathlib import Path
  37. from typing import BinaryIO, Callable, ClassVar, Optional, Union
  38. from dulwich import porcelain
  39. from .bundle import create_bundle_from_repo, read_bundle, write_bundle
  40. from .client import GitProtocolError, get_transport_and_path
  41. from .errors import ApplyDeltaError
  42. from .index import Index
  43. from .objects import Commit, valid_hexsha
  44. from .objectspec import parse_commit_range
  45. from .pack import Pack, sha_to_hex
  46. from .patch import DiffAlgorithmNotAvailable
  47. from .repo import Repo
  48. logger = logging.getLogger(__name__)
  49. def to_display_str(value: Union[bytes, str]) -> str:
  50. """Convert a bytes or string value to a display string.
  51. Args:
  52. value: The value to convert (bytes or str)
  53. Returns:
  54. A string suitable for display
  55. """
  56. if isinstance(value, bytes):
  57. return value.decode("utf-8", "replace")
  58. return value
  59. class CommitMessageError(Exception):
  60. """Raised when there's an issue with the commit message."""
  61. def signal_int(signal: int, frame) -> None:
  62. """Handle interrupt signal by exiting.
  63. Args:
  64. signal: Signal number
  65. frame: Current stack frame
  66. """
  67. sys.exit(1)
  68. def signal_quit(signal: int, frame) -> None:
  69. """Handle quit signal by entering debugger.
  70. Args:
  71. signal: Signal number
  72. frame: Current stack frame
  73. """
  74. import pdb
  75. pdb.set_trace()
  76. def parse_relative_time(time_str: str) -> int:
  77. """Parse a relative time string like '2 weeks ago' into seconds.
  78. Args:
  79. time_str: String like '2 weeks ago' or 'now'
  80. Returns:
  81. Number of seconds
  82. Raises:
  83. ValueError: If the time string cannot be parsed
  84. """
  85. if time_str == "now":
  86. return 0
  87. if not time_str.endswith(" ago"):
  88. raise ValueError(f"Invalid relative time format: {time_str}")
  89. parts = time_str[:-4].split()
  90. if len(parts) != 2:
  91. raise ValueError(f"Invalid relative time format: {time_str}")
  92. try:
  93. num = int(parts[0])
  94. unit = parts[1]
  95. multipliers = {
  96. "second": 1,
  97. "seconds": 1,
  98. "minute": 60,
  99. "minutes": 60,
  100. "hour": 3600,
  101. "hours": 3600,
  102. "day": 86400,
  103. "days": 86400,
  104. "week": 604800,
  105. "weeks": 604800,
  106. }
  107. if unit in multipliers:
  108. return num * multipliers[unit]
  109. else:
  110. raise ValueError(f"Unknown time unit: {unit}")
  111. except ValueError as e:
  112. if "invalid literal" in str(e):
  113. raise ValueError(f"Invalid number in relative time: {parts[0]}")
  114. raise
  115. def format_bytes(bytes: float) -> str:
  116. """Format bytes as human-readable string.
  117. Args:
  118. bytes: Number of bytes
  119. Returns:
  120. Human-readable string like "1.5 MB"
  121. """
  122. for unit in ["B", "KB", "MB", "GB"]:
  123. if bytes < 1024.0:
  124. return f"{bytes:.1f} {unit}"
  125. bytes /= 1024.0
  126. return f"{bytes:.1f} TB"
  127. def launch_editor(template_content: bytes = b"") -> bytes:
  128. """Launch an editor for the user to enter text.
  129. Args:
  130. template_content: Initial content for the editor
  131. Returns:
  132. The edited content as bytes
  133. """
  134. # Determine which editor to use
  135. editor = os.environ.get("GIT_EDITOR") or os.environ.get("EDITOR") or "vi"
  136. # Create a temporary file
  137. with tempfile.NamedTemporaryFile(mode="wb", delete=False, suffix=".txt") as f:
  138. temp_file = f.name
  139. f.write(template_content)
  140. try:
  141. # Launch the editor
  142. subprocess.run([editor, temp_file], check=True)
  143. # Read the edited content
  144. with open(temp_file, "rb") as f:
  145. content = f.read()
  146. return content
  147. finally:
  148. # Clean up the temporary file
  149. os.unlink(temp_file)
  150. class PagerBuffer:
  151. """Binary buffer wrapper for Pager to mimic sys.stdout.buffer."""
  152. def __init__(self, pager: "Pager") -> None:
  153. """Initialize PagerBuffer.
  154. Args:
  155. pager: Pager instance to wrap
  156. """
  157. self.pager = pager
  158. def write(self, data: bytes) -> int:
  159. """Write bytes to pager."""
  160. if isinstance(data, bytes):
  161. text = data.decode("utf-8", errors="replace")
  162. return self.pager.write(text)
  163. return self.pager.write(data)
  164. def flush(self) -> None:
  165. """Flush the pager."""
  166. return self.pager.flush()
  167. def writelines(self, lines) -> None:
  168. """Write multiple lines to pager."""
  169. for line in lines:
  170. self.write(line)
  171. def readable(self) -> bool:
  172. """Return whether the buffer is readable (it's not)."""
  173. return False
  174. def writable(self) -> bool:
  175. """Return whether the buffer is writable."""
  176. return not self.pager._closed
  177. def seekable(self) -> bool:
  178. """Return whether the buffer is seekable (it's not)."""
  179. return False
  180. def close(self) -> None:
  181. """Close the pager."""
  182. return self.pager.close()
  183. @property
  184. def closed(self) -> bool:
  185. """Return whether the buffer is closed."""
  186. return self.pager.closed
  187. class Pager:
  188. """File-like object that pages output through external pager programs."""
  189. def __init__(self, pager_cmd: str = "cat") -> None:
  190. """Initialize Pager.
  191. Args:
  192. pager_cmd: Command to use for paging (default: "cat")
  193. """
  194. self.pager_process: Optional[subprocess.Popen] = None
  195. self.buffer = PagerBuffer(self)
  196. self._closed = False
  197. self.pager_cmd = pager_cmd
  198. self._pager_died = False
  199. def _get_pager_command(self) -> str:
  200. """Get the pager command to use."""
  201. return self.pager_cmd
  202. def _ensure_pager_started(self) -> None:
  203. """Start the pager process if not already started."""
  204. if self.pager_process is None and not self._closed:
  205. try:
  206. pager_cmd = self._get_pager_command()
  207. self.pager_process = subprocess.Popen(
  208. pager_cmd,
  209. shell=True,
  210. stdin=subprocess.PIPE,
  211. stdout=sys.stdout,
  212. stderr=sys.stderr,
  213. text=True,
  214. )
  215. except (OSError, subprocess.SubprocessError):
  216. # Pager failed to start, fall back to direct output
  217. self.pager_process = None
  218. def write(self, text: str) -> int:
  219. """Write text to the pager."""
  220. if self._closed:
  221. raise ValueError("I/O operation on closed file")
  222. # If pager died (user quit), stop writing output
  223. if self._pager_died:
  224. return len(text)
  225. self._ensure_pager_started()
  226. if self.pager_process and self.pager_process.stdin:
  227. try:
  228. return self.pager_process.stdin.write(text)
  229. except (OSError, subprocess.SubprocessError, BrokenPipeError):
  230. # Pager died (user quit), stop writing output
  231. self._pager_died = True
  232. return len(text)
  233. else:
  234. # No pager available, write directly to stdout
  235. return sys.stdout.write(text)
  236. def flush(self) -> None:
  237. """Flush the pager."""
  238. if self._closed or self._pager_died:
  239. return
  240. if self.pager_process and self.pager_process.stdin:
  241. try:
  242. self.pager_process.stdin.flush()
  243. except (OSError, subprocess.SubprocessError, BrokenPipeError):
  244. self._pager_died = True
  245. else:
  246. sys.stdout.flush()
  247. def close(self) -> None:
  248. """Close the pager."""
  249. if self._closed:
  250. return
  251. self._closed = True
  252. if self.pager_process:
  253. try:
  254. if self.pager_process.stdin:
  255. self.pager_process.stdin.close()
  256. self.pager_process.wait()
  257. except (OSError, subprocess.SubprocessError):
  258. pass
  259. self.pager_process = None
  260. def __enter__(self) -> "Pager":
  261. """Context manager entry."""
  262. return self
  263. def __exit__(self, exc_type, exc_val, exc_tb) -> None:
  264. """Context manager exit."""
  265. self.close()
  266. # Additional file-like methods for compatibility
  267. def writelines(self, lines) -> None:
  268. """Write a list of lines to the pager."""
  269. if self._pager_died:
  270. return
  271. for line in lines:
  272. self.write(line)
  273. @property
  274. def closed(self) -> bool:
  275. """Return whether the pager is closed."""
  276. return self._closed
  277. def readable(self) -> bool:
  278. """Return whether the pager is readable (it's not)."""
  279. return False
  280. def writable(self) -> bool:
  281. """Return whether the pager is writable."""
  282. return not self._closed
  283. def seekable(self) -> bool:
  284. """Return whether the pager is seekable (it's not)."""
  285. return False
  286. class _StreamContextAdapter:
  287. """Adapter to make streams work with context manager protocol."""
  288. def __init__(self, stream) -> None:
  289. self.stream = stream
  290. # Expose buffer if it exists
  291. if hasattr(stream, "buffer"):
  292. self.buffer = stream.buffer
  293. else:
  294. self.buffer = stream
  295. def __enter__(self):
  296. return self.stream
  297. def __exit__(self, exc_type, exc_val, exc_tb) -> None:
  298. # For stdout/stderr, we don't close them
  299. pass
  300. def __getattr__(self, name: str):
  301. return getattr(self.stream, name)
  302. def get_pager(config=None, cmd_name: Optional[str] = None):
  303. """Get a pager instance if paging should be used, otherwise return sys.stdout.
  304. Args:
  305. config: Optional config instance (e.g., StackedConfig) to read settings from
  306. cmd_name: Optional command name for per-command pager settings
  307. Returns:
  308. Either a wrapped sys.stdout or a Pager instance (both context managers)
  309. """
  310. # Check global pager disable flag
  311. if getattr(get_pager, "_disabled", False):
  312. return _StreamContextAdapter(sys.stdout)
  313. # Don't page if stdout is not a terminal
  314. if not sys.stdout.isatty():
  315. return _StreamContextAdapter(sys.stdout)
  316. # Priority order for pager command (following git's behavior):
  317. # 1. Check pager.<cmd> config (if cmd_name provided)
  318. # 2. Check environment variables: DULWICH_PAGER, GIT_PAGER, PAGER
  319. # 3. Check core.pager config
  320. # 4. Fallback to common pagers
  321. pager_cmd = None
  322. # 1. Check per-command pager config (pager.<cmd>)
  323. if config and cmd_name:
  324. try:
  325. pager_value = config.get(
  326. ("pager",), cmd_name.encode() if isinstance(cmd_name, str) else cmd_name
  327. )
  328. except KeyError:
  329. pass
  330. else:
  331. if pager_value == b"false":
  332. return _StreamContextAdapter(sys.stdout)
  333. elif pager_value != b"true":
  334. # It's a custom pager command
  335. pager_cmd = (
  336. pager_value.decode()
  337. if isinstance(pager_value, bytes)
  338. else pager_value
  339. )
  340. # 2. Check environment variables
  341. if not pager_cmd:
  342. for env_var in ["DULWICH_PAGER", "GIT_PAGER", "PAGER"]:
  343. pager = os.environ.get(env_var)
  344. if pager:
  345. if pager == "false":
  346. return _StreamContextAdapter(sys.stdout)
  347. pager_cmd = pager
  348. break
  349. # 3. Check core.pager config
  350. if not pager_cmd and config:
  351. try:
  352. core_pager = config.get(("core",), b"pager")
  353. except KeyError:
  354. pass
  355. else:
  356. if core_pager == b"false" or core_pager == b"":
  357. return _StreamContextAdapter(sys.stdout)
  358. pager_cmd = (
  359. core_pager.decode() if isinstance(core_pager, bytes) else core_pager
  360. )
  361. # 4. Fallback to common pagers
  362. if not pager_cmd:
  363. for pager in ["less", "more", "cat"]:
  364. if shutil.which(pager):
  365. if pager == "less":
  366. pager_cmd = "less -FRX" # -F: quit if one screen, -R: raw control chars, -X: no init/deinit
  367. else:
  368. pager_cmd = pager
  369. break
  370. else:
  371. pager_cmd = "cat" # Ultimate fallback
  372. return Pager(pager_cmd)
  373. def disable_pager() -> None:
  374. """Disable pager for this session."""
  375. get_pager._disabled = True # type: ignore[attr-defined]
  376. def enable_pager() -> None:
  377. """Enable pager for this session."""
  378. get_pager._disabled = False # type: ignore[attr-defined]
  379. class Command:
  380. """A Dulwich subcommand."""
  381. def run(self, args) -> Optional[int]:
  382. """Run the command."""
  383. raise NotImplementedError(self.run)
  384. class cmd_archive(Command):
  385. """Create an archive of files from a named tree."""
  386. def run(self, args) -> None:
  387. """Execute the archive command.
  388. Args:
  389. args: Command line arguments
  390. """
  391. parser = argparse.ArgumentParser()
  392. parser.add_argument(
  393. "--remote",
  394. type=str,
  395. help="Retrieve archive from specified remote repo",
  396. )
  397. parser.add_argument("committish", type=str, nargs="?")
  398. args = parser.parse_args(args)
  399. if args.remote:
  400. client, path = get_transport_and_path(args.remote)
  401. client.archive(
  402. path,
  403. args.committish,
  404. sys.stdout.write,
  405. write_error=sys.stderr.write,
  406. )
  407. else:
  408. # Use binary buffer for archive output
  409. outstream: BinaryIO = sys.stdout.buffer
  410. errstream: BinaryIO = sys.stderr.buffer
  411. porcelain.archive(
  412. ".",
  413. args.committish,
  414. outstream=outstream,
  415. errstream=errstream,
  416. )
  417. class cmd_add(Command):
  418. """Add file contents to the index."""
  419. def run(self, argv) -> None:
  420. """Execute the add command.
  421. Args:
  422. argv: Command line arguments
  423. """
  424. parser = argparse.ArgumentParser()
  425. parser.add_argument("path", nargs="+")
  426. args = parser.parse_args(argv)
  427. # Convert '.' to None to add all files
  428. paths = args.path
  429. if len(paths) == 1 and paths[0] == ".":
  430. paths = None
  431. porcelain.add(".", paths=paths)
  432. class cmd_annotate(Command):
  433. """Annotate each line in a file with commit information."""
  434. def run(self, argv) -> None:
  435. """Execute the annotate command.
  436. Args:
  437. argv: Command line arguments
  438. """
  439. parser = argparse.ArgumentParser()
  440. parser.add_argument("path", help="Path to file to annotate")
  441. parser.add_argument("committish", nargs="?", help="Commit to start from")
  442. args = parser.parse_args(argv)
  443. with Repo(".") as repo:
  444. config = repo.get_config_stack()
  445. with get_pager(config=config, cmd_name="annotate") as outstream:
  446. results = porcelain.annotate(repo, args.path, args.committish)
  447. for (commit, entry), line in results:
  448. # Show shortened commit hash and line content
  449. commit_hash = commit.id[:8]
  450. outstream.write(f"{commit_hash.decode()} {line.decode()}\n")
  451. class cmd_blame(Command):
  452. """Show what revision and author last modified each line of a file."""
  453. def run(self, argv) -> None:
  454. """Execute the blame command.
  455. Args:
  456. argv: Command line arguments
  457. """
  458. # blame is an alias for annotate
  459. cmd_annotate().run(argv)
  460. class cmd_rm(Command):
  461. """Remove files from the working tree and from the index."""
  462. def run(self, argv) -> None:
  463. """Execute the rm command.
  464. Args:
  465. argv: Command line arguments
  466. """
  467. parser = argparse.ArgumentParser()
  468. parser.add_argument(
  469. "--cached", action="store_true", help="Remove from index only"
  470. )
  471. parser.add_argument("path", type=Path, nargs="+")
  472. args = parser.parse_args(argv)
  473. porcelain.remove(".", paths=args.path, cached=args.cached)
  474. class cmd_mv(Command):
  475. """Move or rename a file, a directory, or a symlink."""
  476. def run(self, argv) -> None:
  477. """Execute the mv command.
  478. Args:
  479. argv: Command line arguments
  480. """
  481. parser = argparse.ArgumentParser()
  482. parser.add_argument(
  483. "-f",
  484. "--force",
  485. action="store_true",
  486. help="Force move even if destination exists",
  487. )
  488. parser.add_argument("source", type=Path)
  489. parser.add_argument("destination", type=Path)
  490. args = parser.parse_args(argv)
  491. porcelain.mv(".", args.source, args.destination, force=args.force)
  492. class cmd_fetch_pack(Command):
  493. """Receive missing objects from another repository."""
  494. def run(self, argv) -> None:
  495. """Execute the fetch-pack command.
  496. Args:
  497. argv: Command line arguments
  498. """
  499. parser = argparse.ArgumentParser()
  500. parser.add_argument("--all", action="store_true")
  501. parser.add_argument("location", nargs="?", type=str)
  502. parser.add_argument("refs", nargs="*", type=str)
  503. args = parser.parse_args(argv)
  504. client, path = get_transport_and_path(args.location)
  505. r = Repo(".")
  506. if args.all:
  507. determine_wants = r.object_store.determine_wants_all
  508. else:
  509. def determine_wants(refs, depth: Optional[int] = None):
  510. return [y.encode("utf-8") for y in args.refs if y not in r.object_store]
  511. client.fetch(path, r, determine_wants)
  512. class cmd_fetch(Command):
  513. """Download objects and refs from another repository."""
  514. def run(self, args) -> None:
  515. """Execute the fetch command.
  516. Args:
  517. args: Command line arguments
  518. """
  519. parser = argparse.ArgumentParser()
  520. parser.add_argument("location", help="Remote location to fetch from")
  521. args = parser.parse_args(args)
  522. client, path = get_transport_and_path(args.location)
  523. r = Repo(".")
  524. def progress(msg: bytes) -> None:
  525. sys.stdout.buffer.write(msg)
  526. result = client.fetch(path, r, progress=progress)
  527. logger.info("Remote refs:")
  528. for ref, sha in result.refs.items():
  529. if sha is not None:
  530. logger.info("%s → %s", ref.decode(), sha.decode())
  531. class cmd_for_each_ref(Command):
  532. """Output information on each ref."""
  533. def run(self, args) -> None:
  534. """Execute the for-each-ref command.
  535. Args:
  536. args: Command line arguments
  537. """
  538. parser = argparse.ArgumentParser()
  539. parser.add_argument("pattern", type=str, nargs="?")
  540. args = parser.parse_args(args)
  541. for sha, object_type, ref in porcelain.for_each_ref(".", args.pattern):
  542. logger.info("%s %s\t%s", sha.decode(), object_type.decode(), ref.decode())
  543. class cmd_fsck(Command):
  544. """Verify the connectivity and validity of objects in the database."""
  545. def run(self, args) -> None:
  546. """Execute the fsck command.
  547. Args:
  548. args: Command line arguments
  549. """
  550. parser = argparse.ArgumentParser()
  551. parser.parse_args(args)
  552. for obj, msg in porcelain.fsck("."):
  553. logger.info("%s: %s", obj.decode() if isinstance(obj, bytes) else obj, msg)
  554. class cmd_log(Command):
  555. """Show commit logs."""
  556. def run(self, args) -> None:
  557. """Execute the log command.
  558. Args:
  559. args: Command line arguments
  560. """
  561. parser = argparse.ArgumentParser()
  562. parser.add_argument(
  563. "--reverse",
  564. action="store_true",
  565. help="Reverse order in which entries are printed",
  566. )
  567. parser.add_argument(
  568. "--name-status",
  569. action="store_true",
  570. help="Print name/status for each changed file",
  571. )
  572. parser.add_argument("paths", nargs="*", help="Paths to show log for")
  573. args = parser.parse_args(args)
  574. with Repo(".") as repo:
  575. config = repo.get_config_stack()
  576. with get_pager(config=config, cmd_name="log") as outstream:
  577. porcelain.log(
  578. repo,
  579. paths=args.paths,
  580. reverse=args.reverse,
  581. name_status=args.name_status,
  582. outstream=outstream,
  583. )
  584. class cmd_diff(Command):
  585. """Show changes between commits, commit and working tree, etc."""
  586. def run(self, args) -> None:
  587. """Execute the diff command.
  588. Args:
  589. args: Command line arguments
  590. """
  591. parser = argparse.ArgumentParser()
  592. parser.add_argument(
  593. "committish", nargs="*", default=[], help="Commits or refs to compare"
  594. )
  595. parser.add_argument("--staged", action="store_true", help="Show staged changes")
  596. parser.add_argument(
  597. "--cached",
  598. action="store_true",
  599. help="Show staged changes (same as --staged)",
  600. )
  601. parser.add_argument(
  602. "--color",
  603. choices=["always", "never", "auto"],
  604. default="auto",
  605. help="Use colored output (requires rich)",
  606. )
  607. parser.add_argument(
  608. "--patience",
  609. action="store_true",
  610. help="Use patience diff algorithm",
  611. )
  612. parser.add_argument(
  613. "--diff-algorithm",
  614. choices=["myers", "patience"],
  615. default="myers",
  616. help="Choose a diff algorithm",
  617. )
  618. parser.add_argument(
  619. "--", dest="separator", action="store_true", help=argparse.SUPPRESS
  620. )
  621. parser.add_argument("paths", nargs="*", default=[], help="Paths to limit diff")
  622. # Handle the -- separator for paths
  623. if "--" in args:
  624. sep_index = args.index("--")
  625. parsed_args = parser.parse_args(args[:sep_index])
  626. parsed_args.paths = args[sep_index + 1 :]
  627. else:
  628. parsed_args = parser.parse_args(args)
  629. args = parsed_args
  630. # Determine diff algorithm
  631. diff_algorithm = args.diff_algorithm
  632. if args.patience:
  633. diff_algorithm = "patience"
  634. # Determine if we should use color
  635. def _should_use_color():
  636. if args.color == "always":
  637. return True
  638. elif args.color == "never":
  639. return False
  640. else: # auto
  641. return sys.stdout.isatty()
  642. def _create_output_stream(outstream):
  643. """Create output stream, optionally with colorization."""
  644. if not _should_use_color():
  645. return outstream.buffer
  646. from .diff import ColorizedDiffStream
  647. if not ColorizedDiffStream.is_available():
  648. if args.color == "always":
  649. raise ImportError(
  650. "Rich is required for colored output. Install with: pip install 'dulwich[colordiff]'"
  651. )
  652. else:
  653. logging.warning(
  654. "Rich not available, disabling colored output. Install with: pip install 'dulwich[colordiff]'"
  655. )
  656. return outstream.buffer
  657. return ColorizedDiffStream(outstream.buffer)
  658. with Repo(".") as repo:
  659. config = repo.get_config_stack()
  660. with get_pager(config=config, cmd_name="diff") as outstream:
  661. output_stream = _create_output_stream(outstream)
  662. try:
  663. if len(args.committish) == 0:
  664. # Show diff for working tree or staged changes
  665. porcelain.diff(
  666. repo,
  667. staged=(args.staged or args.cached),
  668. paths=args.paths or None,
  669. outstream=output_stream,
  670. diff_algorithm=diff_algorithm,
  671. )
  672. elif len(args.committish) == 1:
  673. # Show diff between working tree and specified commit
  674. if args.staged or args.cached:
  675. parser.error(
  676. "--staged/--cached cannot be used with commits"
  677. )
  678. porcelain.diff(
  679. repo,
  680. commit=args.committish[0],
  681. staged=False,
  682. paths=args.paths or None,
  683. outstream=output_stream,
  684. diff_algorithm=diff_algorithm,
  685. )
  686. elif len(args.committish) == 2:
  687. # Show diff between two commits
  688. porcelain.diff(
  689. repo,
  690. commit=args.committish[0],
  691. commit2=args.committish[1],
  692. paths=args.paths or None,
  693. outstream=output_stream,
  694. diff_algorithm=diff_algorithm,
  695. )
  696. else:
  697. parser.error("Too many arguments - specify at most two commits")
  698. except DiffAlgorithmNotAvailable as e:
  699. sys.stderr.write(f"fatal: {e}\n")
  700. sys.exit(1)
  701. # Flush any remaining output
  702. if hasattr(output_stream, "flush"):
  703. output_stream.flush()
  704. class cmd_dump_pack(Command):
  705. """Dump the contents of a pack file for debugging."""
  706. def run(self, args) -> None:
  707. """Execute the dump-pack command.
  708. Args:
  709. args: Command line arguments
  710. """
  711. parser = argparse.ArgumentParser()
  712. parser.add_argument("filename", help="Pack file to dump")
  713. args = parser.parse_args(args)
  714. basename, _ = os.path.splitext(args.filename)
  715. x = Pack(basename)
  716. logger.info("Object names checksum: %s", x.name().decode("ascii", "replace"))
  717. logger.info("Checksum: %r", sha_to_hex(x.get_stored_checksum()))
  718. x.check()
  719. logger.info("Length: %d", len(x))
  720. for name in x:
  721. try:
  722. logger.info("\t%s", x[name])
  723. except KeyError as k:
  724. logger.error(
  725. "\t%s: Unable to resolve base %r",
  726. name.decode("ascii", "replace"),
  727. k,
  728. )
  729. except ApplyDeltaError as e:
  730. logger.error(
  731. "\t%s: Unable to apply delta: %r",
  732. name.decode("ascii", "replace"),
  733. e,
  734. )
  735. class cmd_dump_index(Command):
  736. """Show information about a pack index file."""
  737. def run(self, args) -> None:
  738. """Execute the dump-index command.
  739. Args:
  740. args: Command line arguments
  741. """
  742. parser = argparse.ArgumentParser()
  743. parser.add_argument("filename", help="Index file to dump")
  744. args = parser.parse_args(args)
  745. idx = Index(args.filename)
  746. for o in idx:
  747. logger.info("%s %s", o, idx[o])
  748. class cmd_init(Command):
  749. """Create an empty Git repository or reinitialize an existing one."""
  750. def run(self, args) -> None:
  751. """Execute the init command.
  752. Args:
  753. args: Command line arguments
  754. """
  755. parser = argparse.ArgumentParser()
  756. parser.add_argument(
  757. "--bare", action="store_true", help="Create a bare repository"
  758. )
  759. parser.add_argument(
  760. "path", nargs="?", default=os.getcwd(), help="Repository path"
  761. )
  762. args = parser.parse_args(args)
  763. porcelain.init(args.path, bare=args.bare)
  764. class cmd_clone(Command):
  765. """Clone a repository into a new directory."""
  766. def run(self, args) -> None:
  767. """Execute the clone command.
  768. Args:
  769. args: Command line arguments
  770. """
  771. parser = argparse.ArgumentParser()
  772. parser.add_argument(
  773. "--bare",
  774. help="Whether to create a bare repository.",
  775. action="store_true",
  776. )
  777. parser.add_argument("--depth", type=int, help="Depth at which to fetch")
  778. parser.add_argument(
  779. "-b",
  780. "--branch",
  781. type=str,
  782. help="Check out branch instead of branch pointed to by remote HEAD",
  783. )
  784. parser.add_argument(
  785. "--refspec",
  786. type=str,
  787. help="References to fetch",
  788. action="append",
  789. )
  790. parser.add_argument(
  791. "--filter",
  792. dest="filter_spec",
  793. type=str,
  794. help="git-rev-list-style object filter",
  795. )
  796. parser.add_argument(
  797. "--protocol",
  798. type=int,
  799. help="Git protocol version to use",
  800. )
  801. parser.add_argument(
  802. "--recurse-submodules",
  803. action="store_true",
  804. help="Initialize and clone submodules",
  805. )
  806. parser.add_argument("source", help="Repository to clone from")
  807. parser.add_argument("target", nargs="?", help="Directory to clone into")
  808. args = parser.parse_args(args)
  809. try:
  810. porcelain.clone(
  811. args.source,
  812. args.target,
  813. bare=args.bare,
  814. depth=args.depth,
  815. branch=args.branch,
  816. refspec=args.refspec,
  817. filter_spec=args.filter_spec,
  818. protocol_version=args.protocol,
  819. recurse_submodules=args.recurse_submodules,
  820. )
  821. except GitProtocolError as e:
  822. logging.exception(e)
  823. def _get_commit_message_with_template(initial_message, repo=None, commit=None):
  824. """Get commit message with an initial message template."""
  825. # Start with the initial message
  826. template = initial_message
  827. if template and not template.endswith(b"\n"):
  828. template += b"\n"
  829. template += b"\n"
  830. template += b"# Please enter the commit message for your changes. Lines starting\n"
  831. template += b"# with '#' will be ignored, and an empty message aborts the commit.\n"
  832. template += b"#\n"
  833. # Add branch info if repo is provided
  834. if repo:
  835. try:
  836. ref_names, ref_sha = repo.refs.follow(b"HEAD")
  837. ref_path = ref_names[-1] # Get the final reference
  838. if ref_path.startswith(b"refs/heads/"):
  839. branch = ref_path[11:] # Remove 'refs/heads/' prefix
  840. else:
  841. branch = ref_path
  842. template += b"# On branch %s\n" % branch
  843. except (KeyError, IndexError):
  844. template += b"# On branch (unknown)\n"
  845. template += b"#\n"
  846. template += b"# Changes to be committed:\n"
  847. # Launch editor
  848. content = launch_editor(template)
  849. # Remove comment lines and strip
  850. lines = content.split(b"\n")
  851. message_lines = [line for line in lines if not line.strip().startswith(b"#")]
  852. message = b"\n".join(message_lines).strip()
  853. if not message:
  854. raise CommitMessageError("Aborting commit due to empty commit message")
  855. return message
  856. class cmd_commit(Command):
  857. """Record changes to the repository."""
  858. def run(self, args) -> Optional[int]:
  859. """Execute the commit command.
  860. Args:
  861. args: Command line arguments
  862. """
  863. parser = argparse.ArgumentParser()
  864. parser.add_argument("--message", "-m", help="Commit message")
  865. parser.add_argument(
  866. "-a",
  867. "--all",
  868. action="store_true",
  869. help="Automatically stage all tracked files that have been modified",
  870. )
  871. parser.add_argument(
  872. "--amend",
  873. action="store_true",
  874. help="Replace the tip of the current branch by creating a new commit",
  875. )
  876. args = parser.parse_args(args)
  877. message: Union[bytes, str, Callable]
  878. if args.message:
  879. message = args.message
  880. elif args.amend:
  881. # For amend, create a callable that opens editor with original message pre-populated
  882. def get_amend_message(repo, commit):
  883. # Get the original commit message from current HEAD
  884. try:
  885. head_commit = repo[repo.head()]
  886. original_message = head_commit.message
  887. except KeyError:
  888. original_message = b""
  889. # Open editor with original message
  890. return _get_commit_message_with_template(original_message, repo, commit)
  891. message = get_amend_message
  892. else:
  893. # For regular commits, use empty template
  894. def get_regular_message(repo, commit):
  895. return _get_commit_message_with_template(b"", repo, commit)
  896. message = get_regular_message
  897. try:
  898. porcelain.commit(".", message=message, all=args.all, amend=args.amend)
  899. except CommitMessageError as e:
  900. logging.exception(e)
  901. return 1
  902. return None
  903. class cmd_commit_tree(Command):
  904. """Create a new commit object from a tree."""
  905. def run(self, args) -> None:
  906. """Execute the commit-tree command.
  907. Args:
  908. args: Command line arguments
  909. """
  910. parser = argparse.ArgumentParser()
  911. parser.add_argument("--message", "-m", required=True, help="Commit message")
  912. parser.add_argument("tree", help="Tree SHA to commit")
  913. args = parser.parse_args(args)
  914. porcelain.commit_tree(".", tree=args.tree, message=args.message)
  915. class cmd_update_server_info(Command):
  916. """Update auxiliary info file to help dumb servers."""
  917. def run(self, args) -> None:
  918. """Execute the update-server-info command.
  919. Args:
  920. args: Command line arguments
  921. """
  922. porcelain.update_server_info(".")
  923. class cmd_symbolic_ref(Command):
  924. """Read, modify and delete symbolic refs."""
  925. def run(self, args) -> Optional[int]:
  926. """Execute the symbolic-ref command.
  927. Args:
  928. args: Command line arguments
  929. """
  930. parser = argparse.ArgumentParser()
  931. parser.add_argument("name", help="Symbolic reference name")
  932. parser.add_argument("ref", nargs="?", help="Target reference")
  933. parser.add_argument("--force", action="store_true", help="Force update")
  934. args = parser.parse_args(args)
  935. # If ref is provided, we're setting; otherwise we're reading
  936. if args.ref:
  937. # Set symbolic reference
  938. from .repo import Repo
  939. with Repo(".") as repo:
  940. repo.refs.set_symbolic_ref(args.name.encode(), args.ref.encode())
  941. return 0
  942. else:
  943. # Read symbolic reference
  944. from .repo import Repo
  945. with Repo(".") as repo:
  946. try:
  947. target = repo.refs.read_ref(args.name.encode())
  948. if target.startswith(b"ref: "):
  949. logger.info(target[5:].decode())
  950. else:
  951. logger.info(target.decode())
  952. return 0
  953. except KeyError:
  954. logging.error("fatal: ref '%s' is not a symbolic ref", args.name)
  955. return 1
  956. class cmd_pack_refs(Command):
  957. """Pack heads and tags for efficient repository access."""
  958. def run(self, argv) -> None:
  959. """Execute the pack-refs command.
  960. Args:
  961. argv: Command line arguments
  962. """
  963. parser = argparse.ArgumentParser()
  964. parser.add_argument("--all", action="store_true")
  965. # ignored, we never prune
  966. parser.add_argument("--no-prune", action="store_true")
  967. args = parser.parse_args(argv)
  968. porcelain.pack_refs(".", all=args.all)
  969. class cmd_show(Command):
  970. """Show various types of objects."""
  971. def run(self, argv) -> None:
  972. """Execute the show command.
  973. Args:
  974. argv: Command line arguments
  975. """
  976. parser = argparse.ArgumentParser()
  977. parser.add_argument("objectish", type=str, nargs="*")
  978. parser.add_argument(
  979. "--color",
  980. choices=["always", "never", "auto"],
  981. default="auto",
  982. help="Use colored output (requires rich)",
  983. )
  984. args = parser.parse_args(argv)
  985. # Determine if we should use color
  986. def _should_use_color():
  987. if args.color == "always":
  988. return True
  989. elif args.color == "never":
  990. return False
  991. else: # auto
  992. return sys.stdout.isatty()
  993. def _create_output_stream(outstream):
  994. """Create output stream, optionally with colorization."""
  995. if not _should_use_color():
  996. return outstream
  997. from .diff import ColorizedDiffStream
  998. if not ColorizedDiffStream.is_available():
  999. if args.color == "always":
  1000. raise ImportError(
  1001. "Rich is required for colored output. Install with: pip install 'dulwich[colordiff]'"
  1002. )
  1003. else:
  1004. logging.warning(
  1005. "Rich not available, disabling colored output. Install with: pip install 'dulwich[colordiff]'"
  1006. )
  1007. return outstream
  1008. return ColorizedDiffStream(outstream.buffer)
  1009. with Repo(".") as repo:
  1010. config = repo.get_config_stack()
  1011. with get_pager(config=config, cmd_name="show") as outstream:
  1012. output_stream = _create_output_stream(outstream)
  1013. porcelain.show(repo, args.objectish or None, outstream=output_stream)
  1014. class cmd_diff_tree(Command):
  1015. """Compare the content and mode of trees."""
  1016. def run(self, args) -> None:
  1017. """Execute the diff-tree command.
  1018. Args:
  1019. args: Command line arguments
  1020. """
  1021. parser = argparse.ArgumentParser()
  1022. parser.add_argument("old_tree", help="Old tree SHA")
  1023. parser.add_argument("new_tree", help="New tree SHA")
  1024. args = parser.parse_args(args)
  1025. porcelain.diff_tree(".", args.old_tree, args.new_tree)
  1026. class cmd_rev_list(Command):
  1027. """List commit objects in reverse chronological order."""
  1028. def run(self, args) -> None:
  1029. """Execute the rev-list command.
  1030. Args:
  1031. args: Command line arguments
  1032. """
  1033. parser = argparse.ArgumentParser()
  1034. parser.add_argument("commits", nargs="+", help="Commit IDs to list")
  1035. args = parser.parse_args(args)
  1036. porcelain.rev_list(".", args.commits)
  1037. class cmd_tag(Command):
  1038. """Create, list, delete or verify a tag object."""
  1039. def run(self, args) -> None:
  1040. """Execute the tag command.
  1041. Args:
  1042. args: Command line arguments
  1043. """
  1044. parser = argparse.ArgumentParser()
  1045. parser.add_argument(
  1046. "-a",
  1047. "--annotated",
  1048. help="Create an annotated tag.",
  1049. action="store_true",
  1050. )
  1051. parser.add_argument(
  1052. "-s", "--sign", help="Sign the annotated tag.", action="store_true"
  1053. )
  1054. parser.add_argument("tag_name", help="Name of the tag to create")
  1055. args = parser.parse_args(args)
  1056. porcelain.tag_create(
  1057. ".", args.tag_name, annotated=args.annotated, sign=args.sign
  1058. )
  1059. class cmd_repack(Command):
  1060. """Pack unpacked objects in a repository."""
  1061. def run(self, args) -> None:
  1062. """Execute the repack command.
  1063. Args:
  1064. args: Command line arguments
  1065. """
  1066. parser = argparse.ArgumentParser()
  1067. parser.parse_args(args)
  1068. porcelain.repack(".")
  1069. class cmd_reflog(Command):
  1070. """Manage reflog information."""
  1071. def run(self, args) -> None:
  1072. """Execute the reflog command.
  1073. Args:
  1074. args: Command line arguments
  1075. """
  1076. parser = argparse.ArgumentParser()
  1077. parser.add_argument(
  1078. "ref", nargs="?", default="HEAD", help="Reference to show reflog for"
  1079. )
  1080. parser.add_argument(
  1081. "--all", action="store_true", help="Show reflogs for all refs"
  1082. )
  1083. args = parser.parse_args(args)
  1084. with Repo(".") as repo:
  1085. config = repo.get_config_stack()
  1086. with get_pager(config=config, cmd_name="reflog") as outstream:
  1087. if args.all:
  1088. # Show reflogs for all refs
  1089. for ref_bytes, entry in porcelain.reflog(repo, all=True):
  1090. ref_str = ref_bytes.decode("utf-8", "replace")
  1091. short_new = entry.new_sha[:8].decode("ascii")
  1092. outstream.write(
  1093. f"{short_new} {ref_str}: {entry.message.decode('utf-8', 'replace')}\n"
  1094. )
  1095. else:
  1096. ref = (
  1097. args.ref.encode("utf-8")
  1098. if isinstance(args.ref, str)
  1099. else args.ref
  1100. )
  1101. for i, entry in enumerate(porcelain.reflog(repo, ref)):
  1102. # Format similar to git reflog
  1103. from dulwich.reflog import Entry
  1104. assert isinstance(entry, Entry)
  1105. short_new = entry.new_sha[:8].decode("ascii")
  1106. message = (
  1107. entry.message.decode("utf-8", "replace")
  1108. if entry.message
  1109. else ""
  1110. )
  1111. outstream.write(
  1112. f"{short_new} {ref.decode('utf-8', 'replace')}@{{{i}}}: {message}\n"
  1113. )
  1114. class cmd_reset(Command):
  1115. """Reset current HEAD to the specified state."""
  1116. def run(self, args) -> None:
  1117. """Execute the reset command.
  1118. Args:
  1119. args: Command line arguments
  1120. """
  1121. parser = argparse.ArgumentParser()
  1122. mode_group = parser.add_mutually_exclusive_group()
  1123. mode_group.add_argument(
  1124. "--hard", action="store_true", help="Reset working tree and index"
  1125. )
  1126. mode_group.add_argument("--soft", action="store_true", help="Reset only HEAD")
  1127. mode_group.add_argument(
  1128. "--mixed", action="store_true", help="Reset HEAD and index"
  1129. )
  1130. parser.add_argument("treeish", nargs="?", help="Commit/tree to reset to")
  1131. args = parser.parse_args(args)
  1132. if args.hard:
  1133. mode = "hard"
  1134. elif args.soft:
  1135. mode = "soft"
  1136. elif args.mixed:
  1137. mode = "mixed"
  1138. else:
  1139. # Default to mixed behavior
  1140. mode = "mixed"
  1141. # Use the porcelain.reset function for all modes
  1142. porcelain.reset(".", mode=mode, treeish=args.treeish)
  1143. class cmd_revert(Command):
  1144. """Revert some existing commits."""
  1145. def run(self, args) -> None:
  1146. """Execute the revert command.
  1147. Args:
  1148. args: Command line arguments
  1149. """
  1150. parser = argparse.ArgumentParser()
  1151. parser.add_argument(
  1152. "--no-commit",
  1153. "-n",
  1154. action="store_true",
  1155. help="Apply changes but don't create a commit",
  1156. )
  1157. parser.add_argument("-m", "--message", help="Custom commit message")
  1158. parser.add_argument("commits", nargs="+", help="Commits to revert")
  1159. args = parser.parse_args(args)
  1160. result = porcelain.revert(
  1161. ".", commits=args.commits, no_commit=args.no_commit, message=args.message
  1162. )
  1163. if result and not args.no_commit:
  1164. logger.info("[%s] Revert completed", result.decode("ascii")[:7])
  1165. class cmd_daemon(Command):
  1166. """Run a simple Git protocol server."""
  1167. def run(self, args) -> None:
  1168. """Execute the daemon command.
  1169. Args:
  1170. args: Command line arguments
  1171. """
  1172. from dulwich import log_utils
  1173. from .protocol import TCP_GIT_PORT
  1174. parser = argparse.ArgumentParser()
  1175. parser.add_argument(
  1176. "-l",
  1177. "--listen_address",
  1178. default="localhost",
  1179. help="Binding IP address.",
  1180. )
  1181. parser.add_argument(
  1182. "-p",
  1183. "--port",
  1184. type=int,
  1185. default=TCP_GIT_PORT,
  1186. help="Binding TCP port.",
  1187. )
  1188. parser.add_argument(
  1189. "gitdir", nargs="?", default=".", help="Git directory to serve"
  1190. )
  1191. args = parser.parse_args(args)
  1192. log_utils.default_logging_config()
  1193. porcelain.daemon(args.gitdir, address=args.listen_address, port=args.port)
  1194. class cmd_web_daemon(Command):
  1195. """Run a simple HTTP server for Git repositories."""
  1196. def run(self, args) -> None:
  1197. """Execute the web-daemon command.
  1198. Args:
  1199. args: Command line arguments
  1200. """
  1201. from dulwich import log_utils
  1202. parser = argparse.ArgumentParser()
  1203. parser.add_argument(
  1204. "-l",
  1205. "--listen_address",
  1206. default="",
  1207. help="Binding IP address.",
  1208. )
  1209. parser.add_argument(
  1210. "-p",
  1211. "--port",
  1212. type=int,
  1213. default=8000,
  1214. help="Binding TCP port.",
  1215. )
  1216. parser.add_argument(
  1217. "gitdir", nargs="?", default=".", help="Git directory to serve"
  1218. )
  1219. args = parser.parse_args(args)
  1220. log_utils.default_logging_config()
  1221. porcelain.web_daemon(args.gitdir, address=args.listen_address, port=args.port)
  1222. class cmd_write_tree(Command):
  1223. """Create a tree object from the current index."""
  1224. def run(self, args) -> None:
  1225. """Execute the write-tree command.
  1226. Args:
  1227. args: Command line arguments
  1228. """
  1229. parser = argparse.ArgumentParser()
  1230. parser.parse_args(args)
  1231. sys.stdout.write("{}\n".format(porcelain.write_tree(".").decode()))
  1232. class cmd_receive_pack(Command):
  1233. """Receive what is pushed into the repository."""
  1234. def run(self, args) -> None:
  1235. """Execute the receive-pack command.
  1236. Args:
  1237. args: Command line arguments
  1238. """
  1239. parser = argparse.ArgumentParser()
  1240. parser.add_argument("gitdir", nargs="?", default=".", help="Git directory")
  1241. args = parser.parse_args(args)
  1242. porcelain.receive_pack(args.gitdir)
  1243. class cmd_upload_pack(Command):
  1244. """Send objects packed back to git-fetch-pack."""
  1245. def run(self, args) -> None:
  1246. """Execute the upload-pack command.
  1247. Args:
  1248. args: Command line arguments
  1249. """
  1250. parser = argparse.ArgumentParser()
  1251. parser.add_argument("gitdir", nargs="?", default=".", help="Git directory")
  1252. args = parser.parse_args(args)
  1253. porcelain.upload_pack(args.gitdir)
  1254. class cmd_shortlog(Command):
  1255. """Show a shortlog of commits by author."""
  1256. def run(self, args) -> None:
  1257. """Execute the shortlog command with the given CLI arguments.
  1258. Args:
  1259. args: List of command line arguments.
  1260. """
  1261. parser = argparse.ArgumentParser()
  1262. parser.add_argument("gitdir", nargs="?", default=".", help="Git directory")
  1263. parser.add_argument("--summary", action="store_true", help="Show summary only")
  1264. parser.add_argument(
  1265. "--sort", action="store_true", help="Sort authors by commit count"
  1266. )
  1267. args = parser.parse_args(args)
  1268. shortlog_items: list[dict[str, str]] = porcelain.shortlog(
  1269. repo=args.gitdir,
  1270. summary_only=args.summary,
  1271. sort_by_commits=args.sort,
  1272. )
  1273. for item in shortlog_items:
  1274. author: str = item["author"]
  1275. messages: str = item["messages"]
  1276. if args.summary:
  1277. count = len(messages.splitlines())
  1278. sys.stdout.write(f"{count}\t{author}\n")
  1279. else:
  1280. sys.stdout.write(f"{author} ({len(messages.splitlines())}):\n")
  1281. for msg in messages.splitlines():
  1282. sys.stdout.write(f" {msg}\n")
  1283. sys.stdout.write("\n")
  1284. class cmd_status(Command):
  1285. """Show the working tree status."""
  1286. def run(self, args) -> None:
  1287. """Execute the status command.
  1288. Args:
  1289. args: Command line arguments
  1290. """
  1291. parser = argparse.ArgumentParser()
  1292. parser.add_argument("gitdir", nargs="?", default=".", help="Git directory")
  1293. args = parser.parse_args(args)
  1294. status = porcelain.status(args.gitdir)
  1295. if any(names for (kind, names) in status.staged.items()):
  1296. sys.stdout.write("Changes to be committed:\n\n")
  1297. for kind, names in status.staged.items():
  1298. for name in names:
  1299. sys.stdout.write(
  1300. f"\t{kind}: {name.decode(sys.getfilesystemencoding())}\n"
  1301. )
  1302. sys.stdout.write("\n")
  1303. if status.unstaged:
  1304. sys.stdout.write("Changes not staged for commit:\n\n")
  1305. for name in status.unstaged:
  1306. sys.stdout.write(f"\t{name.decode(sys.getfilesystemencoding())}\n")
  1307. sys.stdout.write("\n")
  1308. if status.untracked:
  1309. sys.stdout.write("Untracked files:\n\n")
  1310. for name in status.untracked:
  1311. sys.stdout.write(f"\t{name}\n")
  1312. sys.stdout.write("\n")
  1313. class cmd_ls_remote(Command):
  1314. """List references in a remote repository."""
  1315. def run(self, args) -> None:
  1316. """Execute the ls-remote command.
  1317. Args:
  1318. args: Command line arguments
  1319. """
  1320. parser = argparse.ArgumentParser()
  1321. parser.add_argument(
  1322. "--symref", action="store_true", help="Show symbolic references"
  1323. )
  1324. parser.add_argument("url", help="Remote URL to list references from")
  1325. args = parser.parse_args(args)
  1326. result = porcelain.ls_remote(args.url)
  1327. if args.symref:
  1328. # Show symrefs first, like git does
  1329. for ref, target in sorted(result.symrefs.items()):
  1330. if target:
  1331. sys.stdout.write(f"ref: {target.decode()}\t{ref.decode()}\n")
  1332. # Show regular refs
  1333. for ref in sorted(result.refs):
  1334. sha = result.refs[ref]
  1335. if sha is not None:
  1336. sys.stdout.write(f"{sha.decode()}\t{ref.decode()}\n")
  1337. class cmd_ls_tree(Command):
  1338. """List the contents of a tree object."""
  1339. def run(self, args) -> None:
  1340. """Execute the ls-tree command.
  1341. Args:
  1342. args: Command line arguments
  1343. """
  1344. parser = argparse.ArgumentParser()
  1345. parser.add_argument(
  1346. "-r",
  1347. "--recursive",
  1348. action="store_true",
  1349. help="Recursively list tree contents.",
  1350. )
  1351. parser.add_argument(
  1352. "--name-only", action="store_true", help="Only display name."
  1353. )
  1354. parser.add_argument("treeish", nargs="?", help="Tree-ish to list")
  1355. args = parser.parse_args(args)
  1356. with Repo(".") as repo:
  1357. config = repo.get_config_stack()
  1358. with get_pager(config=config, cmd_name="ls-tree") as outstream:
  1359. porcelain.ls_tree(
  1360. repo,
  1361. args.treeish,
  1362. outstream=outstream,
  1363. recursive=args.recursive,
  1364. name_only=args.name_only,
  1365. )
  1366. class cmd_pack_objects(Command):
  1367. """Create a packed archive of objects."""
  1368. def run(self, args) -> None:
  1369. """Execute the pack-objects command.
  1370. Args:
  1371. args: Command line arguments
  1372. """
  1373. parser = argparse.ArgumentParser()
  1374. parser.add_argument(
  1375. "--stdout", action="store_true", help="Write pack to stdout"
  1376. )
  1377. parser.add_argument("--deltify", action="store_true", help="Create deltas")
  1378. parser.add_argument(
  1379. "--no-reuse-deltas", action="store_true", help="Don't reuse existing deltas"
  1380. )
  1381. parser.add_argument("basename", nargs="?", help="Base name for pack files")
  1382. args = parser.parse_args(args)
  1383. if not args.stdout and not args.basename:
  1384. parser.error("basename required when not using --stdout")
  1385. object_ids = [line.strip().encode() for line in sys.stdin.readlines()]
  1386. deltify = args.deltify
  1387. reuse_deltas = not args.no_reuse_deltas
  1388. if args.stdout:
  1389. packf = getattr(sys.stdout, "buffer", sys.stdout)
  1390. assert isinstance(packf, BinaryIO)
  1391. idxf = None
  1392. close = []
  1393. else:
  1394. packf = open(args.basename + ".pack", "wb")
  1395. idxf = open(args.basename + ".idx", "wb")
  1396. close = [packf, idxf]
  1397. porcelain.pack_objects(
  1398. ".", object_ids, packf, idxf, deltify=deltify, reuse_deltas=reuse_deltas
  1399. )
  1400. for f in close:
  1401. f.close()
  1402. class cmd_unpack_objects(Command):
  1403. """Unpack objects from a packed archive."""
  1404. def run(self, args) -> None:
  1405. """Execute the unpack-objects command.
  1406. Args:
  1407. args: Command line arguments
  1408. """
  1409. parser = argparse.ArgumentParser()
  1410. parser.add_argument("pack_file", help="Pack file to unpack")
  1411. args = parser.parse_args(args)
  1412. count = porcelain.unpack_objects(args.pack_file)
  1413. logger.info("Unpacked %d objects", count)
  1414. class cmd_prune(Command):
  1415. """Prune all unreachable objects from the object database."""
  1416. def run(self, args) -> Optional[int]:
  1417. """Execute the prune command.
  1418. Args:
  1419. args: Command line arguments
  1420. """
  1421. import datetime
  1422. import time
  1423. from dulwich.object_store import DEFAULT_TEMPFILE_GRACE_PERIOD
  1424. parser = argparse.ArgumentParser(
  1425. description="Remove temporary pack files left behind by interrupted operations"
  1426. )
  1427. parser.add_argument(
  1428. "--expire",
  1429. nargs="?",
  1430. const="2.weeks.ago",
  1431. help="Only prune files older than the specified date (default: 2.weeks.ago)",
  1432. )
  1433. parser.add_argument(
  1434. "--dry-run",
  1435. "-n",
  1436. action="store_true",
  1437. help="Only report what would be removed",
  1438. )
  1439. parser.add_argument(
  1440. "--verbose",
  1441. "-v",
  1442. action="store_true",
  1443. help="Report all actions",
  1444. )
  1445. args = parser.parse_args(args)
  1446. # Parse expire grace period
  1447. grace_period = DEFAULT_TEMPFILE_GRACE_PERIOD
  1448. if args.expire:
  1449. try:
  1450. grace_period = parse_relative_time(args.expire)
  1451. except ValueError:
  1452. # Try to parse as absolute date
  1453. try:
  1454. date = datetime.datetime.strptime(args.expire, "%Y-%m-%d")
  1455. grace_period = int(time.time() - date.timestamp())
  1456. except ValueError:
  1457. logger.error("Invalid expire date: %s", args.expire)
  1458. return 1
  1459. # Progress callback
  1460. def progress(msg):
  1461. if args.verbose:
  1462. logger.info(msg)
  1463. try:
  1464. porcelain.prune(
  1465. ".",
  1466. grace_period=grace_period,
  1467. dry_run=args.dry_run,
  1468. progress=progress if args.verbose else None,
  1469. )
  1470. return None
  1471. except porcelain.Error as e:
  1472. logger.error("%s", e)
  1473. return 1
  1474. class cmd_pull(Command):
  1475. """Fetch from and integrate with another repository or a local branch."""
  1476. def run(self, args) -> None:
  1477. """Execute the pull command.
  1478. Args:
  1479. args: Command line arguments
  1480. """
  1481. parser = argparse.ArgumentParser()
  1482. parser.add_argument("from_location", type=str)
  1483. parser.add_argument("refspec", type=str, nargs="*")
  1484. parser.add_argument("--filter", type=str, nargs=1)
  1485. parser.add_argument("--protocol", type=int)
  1486. args = parser.parse_args(args)
  1487. porcelain.pull(
  1488. ".",
  1489. args.from_location or None,
  1490. args.refspec or None,
  1491. filter_spec=args.filter,
  1492. protocol_version=args.protocol or None,
  1493. )
  1494. class cmd_push(Command):
  1495. """Update remote refs along with associated objects."""
  1496. def run(self, argv) -> Optional[int]:
  1497. """Execute the push command.
  1498. Args:
  1499. argv: Command line arguments
  1500. """
  1501. parser = argparse.ArgumentParser()
  1502. parser.add_argument("-f", "--force", action="store_true", help="Force")
  1503. parser.add_argument("to_location", type=str)
  1504. parser.add_argument("refspec", type=str, nargs="*")
  1505. args = parser.parse_args(argv)
  1506. try:
  1507. porcelain.push(
  1508. ".", args.to_location, args.refspec or None, force=args.force
  1509. )
  1510. except porcelain.DivergedBranches:
  1511. sys.stderr.write("Diverged branches; specify --force to override")
  1512. return 1
  1513. return None
  1514. class cmd_remote_add(Command):
  1515. """Add a remote repository."""
  1516. def run(self, args) -> None:
  1517. """Execute the remote-add command.
  1518. Args:
  1519. args: Command line arguments
  1520. """
  1521. parser = argparse.ArgumentParser()
  1522. parser.add_argument("name", help="Name of the remote")
  1523. parser.add_argument("url", help="URL of the remote")
  1524. args = parser.parse_args(args)
  1525. porcelain.remote_add(".", args.name, args.url)
  1526. class SuperCommand(Command):
  1527. """Base class for commands that have subcommands."""
  1528. subcommands: ClassVar[dict[str, type[Command]]] = {}
  1529. default_command: ClassVar[Optional[type[Command]]] = None
  1530. def run(self, args):
  1531. """Execute the subcommand command.
  1532. Args:
  1533. args: Command line arguments
  1534. """
  1535. if not args:
  1536. if self.default_command:
  1537. return self.default_command().run(args)
  1538. else:
  1539. logger.info(
  1540. "Supported subcommands: %s", ", ".join(self.subcommands.keys())
  1541. )
  1542. return False
  1543. cmd = args[0]
  1544. try:
  1545. cmd_kls = self.subcommands[cmd]
  1546. except KeyError:
  1547. logger.error("No such subcommand: %s", args[0])
  1548. sys.exit(1)
  1549. return cmd_kls().run(args[1:])
  1550. class cmd_remote(SuperCommand):
  1551. """Manage set of tracked repositories."""
  1552. subcommands: ClassVar[dict[str, type[Command]]] = {
  1553. "add": cmd_remote_add,
  1554. }
  1555. class cmd_submodule_list(Command):
  1556. """List submodules."""
  1557. def run(self, argv) -> None:
  1558. """Execute the submodule-list command.
  1559. Args:
  1560. argv: Command line arguments
  1561. """
  1562. parser = argparse.ArgumentParser()
  1563. parser.parse_args(argv)
  1564. for path, sha in porcelain.submodule_list("."):
  1565. sys.stdout.write(f" {sha} {path}\n")
  1566. class cmd_submodule_init(Command):
  1567. """Initialize submodules."""
  1568. def run(self, argv) -> None:
  1569. """Execute the submodule-init command.
  1570. Args:
  1571. argv: Command line arguments
  1572. """
  1573. parser = argparse.ArgumentParser()
  1574. parser.parse_args(argv)
  1575. porcelain.submodule_init(".")
  1576. class cmd_submodule_add(Command):
  1577. """Add a submodule."""
  1578. def run(self, argv) -> None:
  1579. """Execute the submodule-add command.
  1580. Args:
  1581. argv: Command line arguments
  1582. """
  1583. parser = argparse.ArgumentParser()
  1584. parser.add_argument("url", help="URL of repository to add as submodule")
  1585. parser.add_argument("path", nargs="?", help="Path where submodule should live")
  1586. parser.add_argument("--name", help="Name for the submodule")
  1587. args = parser.parse_args(argv)
  1588. porcelain.submodule_add(".", args.url, args.path, args.name)
  1589. class cmd_submodule_update(Command):
  1590. """Update submodules."""
  1591. def run(self, argv) -> None:
  1592. """Execute the submodule-update command.
  1593. Args:
  1594. argv: Command line arguments
  1595. """
  1596. parser = argparse.ArgumentParser()
  1597. parser.add_argument(
  1598. "--init", action="store_true", help="Initialize submodules first"
  1599. )
  1600. parser.add_argument(
  1601. "--force",
  1602. action="store_true",
  1603. help="Force update even if local changes exist",
  1604. )
  1605. parser.add_argument(
  1606. "paths", nargs="*", help="Specific submodule paths to update"
  1607. )
  1608. args = parser.parse_args(argv)
  1609. paths = args.paths if args.paths else None
  1610. porcelain.submodule_update(".", paths=paths, init=args.init, force=args.force)
  1611. class cmd_submodule(SuperCommand):
  1612. """Initialize, update or inspect submodules."""
  1613. subcommands: ClassVar[dict[str, type[Command]]] = {
  1614. "add": cmd_submodule_add,
  1615. "init": cmd_submodule_init,
  1616. "list": cmd_submodule_list,
  1617. "update": cmd_submodule_update,
  1618. }
  1619. default_command = cmd_submodule_list
  1620. class cmd_check_ignore(Command):
  1621. """Check whether files are excluded by gitignore."""
  1622. def run(self, args):
  1623. """Execute the check-ignore command.
  1624. Args:
  1625. args: Command line arguments
  1626. """
  1627. parser = argparse.ArgumentParser()
  1628. parser.add_argument("paths", nargs="+", help="Paths to check")
  1629. args = parser.parse_args(args)
  1630. ret = 1
  1631. for path in porcelain.check_ignore(".", args.paths):
  1632. logger.info(path)
  1633. ret = 0
  1634. return ret
  1635. class cmd_check_mailmap(Command):
  1636. """Show canonical names and email addresses of contacts."""
  1637. def run(self, args) -> None:
  1638. """Execute the check-mailmap command.
  1639. Args:
  1640. args: Command line arguments
  1641. """
  1642. parser = argparse.ArgumentParser()
  1643. parser.add_argument("identities", nargs="+", help="Identities to check")
  1644. args = parser.parse_args(args)
  1645. for identity in args.identities:
  1646. canonical_identity = porcelain.check_mailmap(".", identity)
  1647. logger.info(canonical_identity)
  1648. class cmd_branch(Command):
  1649. """List, create, or delete branches."""
  1650. def run(self, args) -> Optional[int]:
  1651. """Execute the branch command.
  1652. Args:
  1653. args: Command line arguments
  1654. """
  1655. parser = argparse.ArgumentParser()
  1656. parser.add_argument(
  1657. "branch",
  1658. type=str,
  1659. nargs="?",
  1660. help="Name of the branch",
  1661. )
  1662. parser.add_argument(
  1663. "-d",
  1664. "--delete",
  1665. action="store_true",
  1666. help="Delete branch",
  1667. )
  1668. parser.add_argument("--all", action="store_true", help="List all branches")
  1669. args = parser.parse_args(args)
  1670. if args.all:
  1671. try:
  1672. branches = porcelain.branch_list(".") + porcelain.branch_remotes_list(
  1673. "."
  1674. )
  1675. for branch in branches:
  1676. sys.stdout.write(f"{branch.decode()}\n")
  1677. return 0
  1678. except porcelain.Error as e:
  1679. sys.stderr.write(f"{e}")
  1680. return 1
  1681. if not args.branch:
  1682. logger.error("Usage: dulwich branch [-d] BRANCH_NAME")
  1683. return 1
  1684. if args.delete:
  1685. porcelain.branch_delete(".", name=args.branch)
  1686. else:
  1687. try:
  1688. porcelain.branch_create(".", name=args.branch)
  1689. except porcelain.Error as e:
  1690. sys.stderr.write(f"{e}")
  1691. return 1
  1692. return 0
  1693. class cmd_checkout(Command):
  1694. """Switch branches or restore working tree files."""
  1695. def run(self, args) -> Optional[int]:
  1696. """Execute the checkout command.
  1697. Args:
  1698. args: Command line arguments
  1699. """
  1700. parser = argparse.ArgumentParser()
  1701. parser.add_argument(
  1702. "target",
  1703. type=str,
  1704. help="Name of the branch, tag, or commit to checkout",
  1705. )
  1706. parser.add_argument(
  1707. "-f",
  1708. "--force",
  1709. action="store_true",
  1710. help="Force checkout",
  1711. )
  1712. parser.add_argument(
  1713. "-b",
  1714. "--new-branch",
  1715. type=str,
  1716. help="Create a new branch at the target and switch to it",
  1717. )
  1718. args = parser.parse_args(args)
  1719. if not args.target:
  1720. logger.error("Usage: dulwich checkout TARGET [--force] [-b NEW_BRANCH]")
  1721. return 1
  1722. try:
  1723. porcelain.checkout(
  1724. ".", target=args.target, force=args.force, new_branch=args.new_branch
  1725. )
  1726. except porcelain.CheckoutError as e:
  1727. sys.stderr.write(f"{e}\n")
  1728. return 1
  1729. return 0
  1730. class cmd_stash_list(Command):
  1731. """List stash entries."""
  1732. def run(self, args) -> None:
  1733. """Execute the stash-list command.
  1734. Args:
  1735. args: Command line arguments
  1736. """
  1737. parser = argparse.ArgumentParser()
  1738. parser.parse_args(args)
  1739. from .repo import Repo
  1740. from .stash import Stash
  1741. with Repo(".") as r:
  1742. stash = Stash.from_repo(r)
  1743. for i, entry in enumerate(stash.stashes()):
  1744. logger.info(
  1745. "stash@{%d}: %s",
  1746. i,
  1747. entry.message.decode("utf-8", "replace").rstrip("\n"),
  1748. )
  1749. class cmd_stash_push(Command):
  1750. """Save your local modifications to a new stash."""
  1751. def run(self, args) -> None:
  1752. """Execute the stash-push command.
  1753. Args:
  1754. args: Command line arguments
  1755. """
  1756. parser = argparse.ArgumentParser()
  1757. parser.parse_args(args)
  1758. porcelain.stash_push(".")
  1759. logger.info("Saved working directory and index state")
  1760. class cmd_stash_pop(Command):
  1761. """Apply a stash and remove it from the stash list."""
  1762. def run(self, args) -> None:
  1763. """Execute the stash-pop command.
  1764. Args:
  1765. args: Command line arguments
  1766. """
  1767. parser = argparse.ArgumentParser()
  1768. parser.parse_args(args)
  1769. porcelain.stash_pop(".")
  1770. logger.info("Restored working directory and index state")
  1771. class cmd_bisect(SuperCommand):
  1772. """Use binary search to find the commit that introduced a bug."""
  1773. subcommands: ClassVar[dict[str, type[Command]]] = {}
  1774. def run(self, args):
  1775. """Execute the bisect command.
  1776. Args:
  1777. args: Command line arguments
  1778. """
  1779. parser = argparse.ArgumentParser(prog="dulwich bisect")
  1780. subparsers = parser.add_subparsers(dest="subcommand", help="bisect subcommands")
  1781. # bisect start
  1782. start_parser = subparsers.add_parser("start", help="Start a new bisect session")
  1783. start_parser.add_argument("bad", nargs="?", help="Bad commit")
  1784. start_parser.add_argument("good", nargs="*", help="Good commit(s)")
  1785. start_parser.add_argument(
  1786. "--no-checkout",
  1787. action="store_true",
  1788. help="Don't checkout commits during bisect",
  1789. )
  1790. start_parser.add_argument(
  1791. "--term-bad", default="bad", help="Term to use for bad commits"
  1792. )
  1793. start_parser.add_argument(
  1794. "--term-good", default="good", help="Term to use for good commits"
  1795. )
  1796. start_parser.add_argument(
  1797. "--", dest="paths", nargs="*", help="Paths to limit bisect to"
  1798. )
  1799. # bisect bad
  1800. bad_parser = subparsers.add_parser("bad", help="Mark a commit as bad")
  1801. bad_parser.add_argument("rev", nargs="?", help="Commit to mark as bad")
  1802. # bisect good
  1803. good_parser = subparsers.add_parser("good", help="Mark a commit as good")
  1804. good_parser.add_argument("rev", nargs="?", help="Commit to mark as good")
  1805. # bisect skip
  1806. skip_parser = subparsers.add_parser("skip", help="Skip commits")
  1807. skip_parser.add_argument("revs", nargs="*", help="Commits to skip")
  1808. # bisect reset
  1809. reset_parser = subparsers.add_parser("reset", help="Reset bisect state")
  1810. reset_parser.add_argument("commit", nargs="?", help="Commit to reset to")
  1811. # bisect log
  1812. subparsers.add_parser("log", help="Show bisect log")
  1813. # bisect replay
  1814. replay_parser = subparsers.add_parser("replay", help="Replay bisect log")
  1815. replay_parser.add_argument("logfile", help="Log file to replay")
  1816. # bisect help
  1817. subparsers.add_parser("help", help="Show help")
  1818. parsed_args = parser.parse_args(args)
  1819. if not parsed_args.subcommand:
  1820. parser.print_help()
  1821. return 1
  1822. try:
  1823. if parsed_args.subcommand == "start":
  1824. next_sha = porcelain.bisect_start(
  1825. bad=parsed_args.bad,
  1826. good=parsed_args.good if parsed_args.good else None,
  1827. paths=parsed_args.paths,
  1828. no_checkout=parsed_args.no_checkout,
  1829. term_bad=parsed_args.term_bad,
  1830. term_good=parsed_args.term_good,
  1831. )
  1832. if next_sha:
  1833. logger.info(
  1834. "Bisecting: checking out '%s'", next_sha.decode("ascii")
  1835. )
  1836. elif parsed_args.subcommand == "bad":
  1837. next_sha = porcelain.bisect_bad(rev=parsed_args.rev)
  1838. if next_sha:
  1839. logger.info(
  1840. "Bisecting: checking out '%s'", next_sha.decode("ascii")
  1841. )
  1842. else:
  1843. # Bisect complete - find the first bad commit
  1844. with porcelain.open_repo_closing(".") as r:
  1845. bad_ref = os.path.join(r.controldir(), "refs", "bisect", "bad")
  1846. with open(bad_ref, "rb") as f:
  1847. bad_sha = f.read().strip()
  1848. commit = r.object_store[bad_sha]
  1849. assert isinstance(commit, Commit)
  1850. message = commit.message.decode(
  1851. "utf-8", errors="replace"
  1852. ).split("\n")[0]
  1853. logger.info(
  1854. "%s is the first bad commit", bad_sha.decode("ascii")
  1855. )
  1856. logger.info("commit %s", bad_sha.decode("ascii"))
  1857. logger.info(" %s", message)
  1858. elif parsed_args.subcommand == "good":
  1859. next_sha = porcelain.bisect_good(rev=parsed_args.rev)
  1860. if next_sha:
  1861. logger.info(
  1862. "Bisecting: checking out '%s'", next_sha.decode("ascii")
  1863. )
  1864. elif parsed_args.subcommand == "skip":
  1865. next_sha = porcelain.bisect_skip(
  1866. revs=parsed_args.revs if parsed_args.revs else None
  1867. )
  1868. if next_sha:
  1869. logger.info(
  1870. "Bisecting: checking out '%s'", next_sha.decode("ascii")
  1871. )
  1872. elif parsed_args.subcommand == "reset":
  1873. porcelain.bisect_reset(commit=parsed_args.commit)
  1874. logger.info("Bisect reset")
  1875. elif parsed_args.subcommand == "log":
  1876. log = porcelain.bisect_log()
  1877. logger.info(log.rstrip())
  1878. elif parsed_args.subcommand == "replay":
  1879. porcelain.bisect_replay(".", log_file=parsed_args.logfile)
  1880. logger.info("Replayed bisect log from %s", parsed_args.logfile)
  1881. elif parsed_args.subcommand == "help":
  1882. parser.print_help()
  1883. except porcelain.Error as e:
  1884. logger.error("%s", e)
  1885. return 1
  1886. except ValueError as e:
  1887. logger.error("%s", e)
  1888. return 1
  1889. return 0
  1890. class cmd_stash(SuperCommand):
  1891. """Stash the changes in a dirty working directory away."""
  1892. subcommands: ClassVar[dict[str, type[Command]]] = {
  1893. "list": cmd_stash_list,
  1894. "pop": cmd_stash_pop,
  1895. "push": cmd_stash_push,
  1896. }
  1897. class cmd_ls_files(Command):
  1898. """Show information about files in the index and working tree."""
  1899. def run(self, args) -> None:
  1900. """Execute the ls-files command.
  1901. Args:
  1902. args: Command line arguments
  1903. """
  1904. parser = argparse.ArgumentParser()
  1905. parser.parse_args(args)
  1906. for name in porcelain.ls_files("."):
  1907. logger.info(name)
  1908. class cmd_describe(Command):
  1909. """Give an object a human readable name based on an available ref."""
  1910. def run(self, args) -> None:
  1911. """Execute the describe command.
  1912. Args:
  1913. args: Command line arguments
  1914. """
  1915. parser = argparse.ArgumentParser()
  1916. parser.parse_args(args)
  1917. logger.info(porcelain.describe("."))
  1918. class cmd_merge(Command):
  1919. """Join two or more development histories together."""
  1920. def run(self, args) -> Optional[int]:
  1921. """Execute the merge command.
  1922. Args:
  1923. args: Command line arguments
  1924. """
  1925. parser = argparse.ArgumentParser()
  1926. parser.add_argument("commit", type=str, help="Commit to merge")
  1927. parser.add_argument(
  1928. "--no-commit", action="store_true", help="Do not create a merge commit"
  1929. )
  1930. parser.add_argument(
  1931. "--no-ff", action="store_true", help="Force create a merge commit"
  1932. )
  1933. parser.add_argument("-m", "--message", type=str, help="Merge commit message")
  1934. args = parser.parse_args(args)
  1935. try:
  1936. merge_commit_id, conflicts = porcelain.merge(
  1937. ".",
  1938. args.commit,
  1939. no_commit=args.no_commit,
  1940. no_ff=args.no_ff,
  1941. message=args.message,
  1942. )
  1943. if conflicts:
  1944. logger.warning("Merge conflicts in %d file(s):", len(conflicts))
  1945. for conflict_path in conflicts:
  1946. logger.warning(" %s", conflict_path.decode())
  1947. logger.error(
  1948. "Automatic merge failed; fix conflicts and then commit the result."
  1949. )
  1950. return 1
  1951. elif merge_commit_id is None and not args.no_commit:
  1952. logger.info("Already up to date.")
  1953. elif args.no_commit:
  1954. logger.info("Automatic merge successful; not committing as requested.")
  1955. else:
  1956. assert merge_commit_id is not None
  1957. logger.info(
  1958. "Merge successful. Created merge commit %s",
  1959. merge_commit_id.decode(),
  1960. )
  1961. return 0
  1962. except porcelain.Error as e:
  1963. logger.error("%s", e)
  1964. return 1
  1965. class cmd_notes_add(Command):
  1966. """Add notes to a commit."""
  1967. def run(self, args) -> None:
  1968. """Execute the notes-add command.
  1969. Args:
  1970. args: Command line arguments
  1971. """
  1972. parser = argparse.ArgumentParser()
  1973. parser.add_argument("object", help="Object to annotate")
  1974. parser.add_argument("-m", "--message", help="Note message", required=True)
  1975. parser.add_argument(
  1976. "--ref", default="commits", help="Notes ref (default: commits)"
  1977. )
  1978. args = parser.parse_args(args)
  1979. porcelain.notes_add(".", args.object, args.message, ref=args.ref)
  1980. class cmd_notes_show(Command):
  1981. """Show notes for a commit."""
  1982. def run(self, args) -> None:
  1983. """Execute the notes-show command.
  1984. Args:
  1985. args: Command line arguments
  1986. """
  1987. parser = argparse.ArgumentParser()
  1988. parser.add_argument("object", help="Object to show notes for")
  1989. parser.add_argument(
  1990. "--ref", default="commits", help="Notes ref (default: commits)"
  1991. )
  1992. args = parser.parse_args(args)
  1993. note = porcelain.notes_show(".", args.object, ref=args.ref)
  1994. if note:
  1995. sys.stdout.buffer.write(note)
  1996. else:
  1997. logger.info("No notes found for object %s", args.object)
  1998. class cmd_notes_remove(Command):
  1999. """Remove notes for a commit."""
  2000. def run(self, args) -> None:
  2001. """Execute the notes-remove command.
  2002. Args:
  2003. args: Command line arguments
  2004. """
  2005. parser = argparse.ArgumentParser()
  2006. parser.add_argument("object", help="Object to remove notes from")
  2007. parser.add_argument(
  2008. "--ref", default="commits", help="Notes ref (default: commits)"
  2009. )
  2010. args = parser.parse_args(args)
  2011. result = porcelain.notes_remove(".", args.object, ref=args.ref)
  2012. if result:
  2013. logger.info("Removed notes for object %s", args.object)
  2014. else:
  2015. logger.info("No notes found for object %s", args.object)
  2016. class cmd_notes_list(Command):
  2017. """List all note objects."""
  2018. def run(self, args) -> None:
  2019. """Execute the notes-list command.
  2020. Args:
  2021. args: Command line arguments
  2022. """
  2023. parser = argparse.ArgumentParser()
  2024. parser.add_argument(
  2025. "--ref", default="commits", help="Notes ref (default: commits)"
  2026. )
  2027. args = parser.parse_args(args)
  2028. notes = porcelain.notes_list(".", ref=args.ref)
  2029. for object_sha, note_content in notes:
  2030. logger.info(object_sha.hex())
  2031. class cmd_notes(SuperCommand):
  2032. """Add or inspect object notes."""
  2033. subcommands: ClassVar[dict[str, type[Command]]] = {
  2034. "add": cmd_notes_add,
  2035. "show": cmd_notes_show,
  2036. "remove": cmd_notes_remove,
  2037. "list": cmd_notes_list,
  2038. }
  2039. default_command = cmd_notes_list
  2040. class cmd_cherry_pick(Command):
  2041. """Apply the changes introduced by some existing commits."""
  2042. def run(self, args) -> Optional[int]:
  2043. """Execute the cherry-pick command.
  2044. Args:
  2045. args: Command line arguments
  2046. """
  2047. parser = argparse.ArgumentParser(
  2048. description="Apply the changes introduced by some existing commits"
  2049. )
  2050. parser.add_argument("commit", nargs="?", help="Commit to cherry-pick")
  2051. parser.add_argument(
  2052. "-n",
  2053. "--no-commit",
  2054. action="store_true",
  2055. help="Apply changes without making a commit",
  2056. )
  2057. parser.add_argument(
  2058. "--continue",
  2059. dest="continue_",
  2060. action="store_true",
  2061. help="Continue after resolving conflicts",
  2062. )
  2063. parser.add_argument(
  2064. "--abort",
  2065. action="store_true",
  2066. help="Abort the current cherry-pick operation",
  2067. )
  2068. args = parser.parse_args(args)
  2069. # Check argument validity
  2070. if args.continue_ or args.abort:
  2071. if args.commit is not None:
  2072. parser.error("Cannot specify commit with --continue or --abort")
  2073. return 1
  2074. else:
  2075. if args.commit is None:
  2076. parser.error("Commit argument is required")
  2077. return 1
  2078. try:
  2079. commit_arg = args.commit
  2080. result = porcelain.cherry_pick(
  2081. ".",
  2082. commit_arg,
  2083. no_commit=args.no_commit,
  2084. continue_=args.continue_,
  2085. abort=args.abort,
  2086. )
  2087. if args.abort:
  2088. logger.info("Cherry-pick aborted.")
  2089. elif args.continue_:
  2090. if result:
  2091. logger.info("Cherry-pick completed: %s", result.decode())
  2092. else:
  2093. logger.info("Cherry-pick completed.")
  2094. elif result is None:
  2095. if args.no_commit:
  2096. logger.info("Cherry-pick applied successfully (no commit created).")
  2097. else:
  2098. # This shouldn't happen unless there were conflicts
  2099. logger.warning("Cherry-pick resulted in conflicts.")
  2100. else:
  2101. logger.info("Cherry-pick successful: %s", result.decode())
  2102. return None
  2103. except porcelain.Error as e:
  2104. logger.error("%s", e)
  2105. return 1
  2106. class cmd_merge_tree(Command):
  2107. """Show three-way merge without touching index."""
  2108. def run(self, args) -> Optional[int]:
  2109. """Execute the merge-tree command.
  2110. Args:
  2111. args: Command line arguments
  2112. """
  2113. parser = argparse.ArgumentParser(
  2114. description="Perform a tree-level merge without touching the working directory"
  2115. )
  2116. parser.add_argument(
  2117. "base_tree",
  2118. nargs="?",
  2119. help="The common ancestor tree (optional, defaults to empty tree)",
  2120. )
  2121. parser.add_argument("our_tree", help="Our side of the merge")
  2122. parser.add_argument("their_tree", help="Their side of the merge")
  2123. parser.add_argument(
  2124. "-z",
  2125. "--name-only",
  2126. action="store_true",
  2127. help="Output only conflict paths, null-terminated",
  2128. )
  2129. args = parser.parse_args(args)
  2130. try:
  2131. # Determine base tree - if only two args provided, base is None
  2132. if args.base_tree is None:
  2133. # Only two arguments provided
  2134. base_tree = None
  2135. our_tree = args.our_tree
  2136. their_tree = args.their_tree
  2137. else:
  2138. # Three arguments provided
  2139. base_tree = args.base_tree
  2140. our_tree = args.our_tree
  2141. their_tree = args.their_tree
  2142. merged_tree_id, conflicts = porcelain.merge_tree(
  2143. ".", base_tree, our_tree, their_tree
  2144. )
  2145. if args.name_only:
  2146. # Output only conflict paths, null-terminated
  2147. for conflict_path in conflicts:
  2148. sys.stdout.buffer.write(conflict_path)
  2149. sys.stdout.buffer.write(b"\0")
  2150. else:
  2151. # Output the merged tree SHA
  2152. logger.info(merged_tree_id.decode("ascii"))
  2153. # Output conflict information
  2154. if conflicts:
  2155. logger.warning("\nConflicts in %d file(s):", len(conflicts))
  2156. for conflict_path in conflicts:
  2157. logger.warning(" %s", conflict_path.decode())
  2158. return None
  2159. except porcelain.Error as e:
  2160. logger.error("%s", e)
  2161. return 1
  2162. except KeyError as e:
  2163. logger.error("Object not found: %s", e)
  2164. return 1
  2165. class cmd_gc(Command):
  2166. """Cleanup unnecessary files and optimize the local repository."""
  2167. def run(self, args) -> Optional[int]:
  2168. """Execute the gc command.
  2169. Args:
  2170. args: Command line arguments
  2171. """
  2172. import datetime
  2173. import time
  2174. parser = argparse.ArgumentParser()
  2175. parser.add_argument(
  2176. "--auto",
  2177. action="store_true",
  2178. help="Only run gc if needed",
  2179. )
  2180. parser.add_argument(
  2181. "--aggressive",
  2182. action="store_true",
  2183. help="Use more aggressive settings",
  2184. )
  2185. parser.add_argument(
  2186. "--no-prune",
  2187. action="store_true",
  2188. help="Do not prune unreachable objects",
  2189. )
  2190. parser.add_argument(
  2191. "--prune",
  2192. nargs="?",
  2193. const="now",
  2194. help="Prune unreachable objects older than date (default: 2 weeks ago)",
  2195. )
  2196. parser.add_argument(
  2197. "--dry-run",
  2198. "-n",
  2199. action="store_true",
  2200. help="Only report what would be done",
  2201. )
  2202. parser.add_argument(
  2203. "--quiet",
  2204. "-q",
  2205. action="store_true",
  2206. help="Only report errors",
  2207. )
  2208. args = parser.parse_args(args)
  2209. # Parse prune grace period
  2210. grace_period = None
  2211. if args.prune:
  2212. try:
  2213. grace_period = parse_relative_time(args.prune)
  2214. except ValueError:
  2215. # Try to parse as absolute date
  2216. try:
  2217. date = datetime.datetime.strptime(args.prune, "%Y-%m-%d")
  2218. grace_period = int(time.time() - date.timestamp())
  2219. except ValueError:
  2220. logger.error("Invalid prune date: %s", args.prune)
  2221. return 1
  2222. elif not args.no_prune:
  2223. # Default to 2 weeks
  2224. grace_period = 1209600
  2225. # Progress callback
  2226. def progress(msg):
  2227. if not args.quiet:
  2228. logger.info(msg)
  2229. try:
  2230. stats = porcelain.gc(
  2231. ".",
  2232. auto=args.auto,
  2233. aggressive=args.aggressive,
  2234. prune=not args.no_prune,
  2235. grace_period=grace_period,
  2236. dry_run=args.dry_run,
  2237. progress=progress if not args.quiet else None,
  2238. )
  2239. # Report results
  2240. if not args.quiet:
  2241. if args.dry_run:
  2242. logger.info("\nDry run results:")
  2243. else:
  2244. logger.info("\nGarbage collection complete:")
  2245. if stats.pruned_objects:
  2246. logger.info(
  2247. " Pruned %d unreachable objects", len(stats.pruned_objects)
  2248. )
  2249. logger.info(" Freed %s", format_bytes(stats.bytes_freed))
  2250. if stats.packs_before != stats.packs_after:
  2251. logger.info(
  2252. " Reduced pack files from %d to %d",
  2253. stats.packs_before,
  2254. stats.packs_after,
  2255. )
  2256. except porcelain.Error as e:
  2257. logger.error("%s", e)
  2258. return 1
  2259. return None
  2260. class cmd_count_objects(Command):
  2261. """Count unpacked number of objects and their disk consumption."""
  2262. def run(self, args) -> None:
  2263. """Execute the count-objects command.
  2264. Args:
  2265. args: Command line arguments
  2266. """
  2267. parser = argparse.ArgumentParser()
  2268. parser.add_argument(
  2269. "-v",
  2270. "--verbose",
  2271. action="store_true",
  2272. help="Display verbose information.",
  2273. )
  2274. args = parser.parse_args(args)
  2275. if args.verbose:
  2276. stats = porcelain.count_objects(".", verbose=True)
  2277. # Display verbose output
  2278. logger.info("count: %d", stats.count)
  2279. logger.info("size: %d", stats.size // 1024) # Size in KiB
  2280. assert stats.in_pack is not None
  2281. logger.info("in-pack: %d", stats.in_pack)
  2282. assert stats.packs is not None
  2283. logger.info("packs: %d", stats.packs)
  2284. assert stats.size_pack is not None
  2285. logger.info("size-pack: %d", stats.size_pack // 1024) # Size in KiB
  2286. else:
  2287. # Simple output
  2288. stats = porcelain.count_objects(".", verbose=False)
  2289. logger.info("%d objects, %d kilobytes", stats.count, stats.size // 1024)
  2290. class cmd_rebase(Command):
  2291. """Reapply commits on top of another base tip."""
  2292. def run(self, args) -> int:
  2293. """Execute the rebase command.
  2294. Args:
  2295. args: Command line arguments
  2296. """
  2297. parser = argparse.ArgumentParser()
  2298. parser.add_argument(
  2299. "upstream", nargs="?", help="Upstream branch to rebase onto"
  2300. )
  2301. parser.add_argument("--onto", type=str, help="Rebase onto specific commit")
  2302. parser.add_argument(
  2303. "--branch", type=str, help="Branch to rebase (default: current)"
  2304. )
  2305. parser.add_argument(
  2306. "-i", "--interactive", action="store_true", help="Interactive rebase"
  2307. )
  2308. parser.add_argument(
  2309. "--edit-todo",
  2310. action="store_true",
  2311. help="Edit the todo list during an interactive rebase",
  2312. )
  2313. parser.add_argument(
  2314. "--abort", action="store_true", help="Abort an in-progress rebase"
  2315. )
  2316. parser.add_argument(
  2317. "--continue",
  2318. dest="continue_rebase",
  2319. action="store_true",
  2320. help="Continue an in-progress rebase",
  2321. )
  2322. parser.add_argument(
  2323. "--skip", action="store_true", help="Skip current commit and continue"
  2324. )
  2325. args = parser.parse_args(args)
  2326. # Handle abort/continue/skip first
  2327. if args.abort:
  2328. try:
  2329. porcelain.rebase(".", args.upstream or "HEAD", abort=True)
  2330. logger.info("Rebase aborted.")
  2331. except porcelain.Error as e:
  2332. logger.error("%s", e)
  2333. return 1
  2334. return 0
  2335. if args.continue_rebase:
  2336. try:
  2337. # Check if interactive rebase is in progress
  2338. if porcelain.is_interactive_rebase("."):
  2339. result = porcelain.rebase(
  2340. ".",
  2341. args.upstream or "HEAD",
  2342. continue_rebase=True,
  2343. interactive=True,
  2344. )
  2345. if result:
  2346. logger.info("Rebase complete.")
  2347. else:
  2348. logger.info("Rebase paused. Use --continue to resume.")
  2349. else:
  2350. new_shas = porcelain.rebase(
  2351. ".", args.upstream or "HEAD", continue_rebase=True
  2352. )
  2353. logger.info("Rebase complete.")
  2354. except porcelain.Error as e:
  2355. logger.error("%s", e)
  2356. return 1
  2357. return 0
  2358. if args.edit_todo:
  2359. # Edit todo list for interactive rebase
  2360. try:
  2361. porcelain.rebase(".", args.upstream or "HEAD", edit_todo=True)
  2362. logger.info("Todo list updated.")
  2363. except porcelain.Error as e:
  2364. logger.error("%s", e)
  2365. return 1
  2366. return 0
  2367. # Normal rebase requires upstream
  2368. if not args.upstream:
  2369. logger.error("Missing required argument 'upstream'")
  2370. return 1
  2371. try:
  2372. if args.interactive:
  2373. # Interactive rebase
  2374. result = porcelain.rebase(
  2375. ".",
  2376. args.upstream,
  2377. onto=args.onto,
  2378. branch=args.branch,
  2379. interactive=True,
  2380. )
  2381. if result:
  2382. logger.info(
  2383. "Interactive rebase started. Edit the todo list and save."
  2384. )
  2385. else:
  2386. logger.info("No commits to rebase.")
  2387. else:
  2388. # Regular rebase
  2389. new_shas = porcelain.rebase(
  2390. ".",
  2391. args.upstream,
  2392. onto=args.onto,
  2393. branch=args.branch,
  2394. )
  2395. if new_shas:
  2396. logger.info("Successfully rebased %d commits.", len(new_shas))
  2397. else:
  2398. logger.info("Already up to date.")
  2399. return 0
  2400. except porcelain.Error as e:
  2401. logger.error("%s", e)
  2402. return 1
  2403. class cmd_filter_branch(Command):
  2404. """Rewrite branches."""
  2405. def run(self, args) -> Optional[int]:
  2406. """Execute the filter-branch command.
  2407. Args:
  2408. args: Command line arguments
  2409. """
  2410. import subprocess
  2411. parser = argparse.ArgumentParser(description="Rewrite branches")
  2412. # Supported Git-compatible options
  2413. parser.add_argument(
  2414. "--subdirectory-filter",
  2415. type=str,
  2416. help="Only include history for subdirectory",
  2417. )
  2418. parser.add_argument("--env-filter", type=str, help="Environment filter command")
  2419. parser.add_argument("--tree-filter", type=str, help="Tree filter command")
  2420. parser.add_argument("--index-filter", type=str, help="Index filter command")
  2421. parser.add_argument("--parent-filter", type=str, help="Parent filter command")
  2422. parser.add_argument("--msg-filter", type=str, help="Message filter command")
  2423. parser.add_argument("--commit-filter", type=str, help="Commit filter command")
  2424. parser.add_argument(
  2425. "--tag-name-filter", type=str, help="Tag name filter command"
  2426. )
  2427. parser.add_argument(
  2428. "--prune-empty", action="store_true", help="Remove empty commits"
  2429. )
  2430. parser.add_argument(
  2431. "--original",
  2432. type=str,
  2433. default="refs/original",
  2434. help="Namespace for original refs",
  2435. )
  2436. parser.add_argument(
  2437. "-f",
  2438. "--force",
  2439. action="store_true",
  2440. help="Force operation even if refs/original/* exists",
  2441. )
  2442. # Branch/ref to rewrite (defaults to HEAD)
  2443. parser.add_argument(
  2444. "branch", nargs="?", default="HEAD", help="Branch or ref to rewrite"
  2445. )
  2446. args = parser.parse_args(args)
  2447. # Track if any filter fails
  2448. filter_error = False
  2449. # Setup environment for filters
  2450. env = os.environ.copy()
  2451. # Helper function to run shell commands
  2452. def run_filter(cmd, input_data=None, cwd=None, extra_env=None):
  2453. nonlocal filter_error
  2454. filter_env = env.copy()
  2455. if extra_env:
  2456. filter_env.update(extra_env)
  2457. result = subprocess.run(
  2458. cmd,
  2459. shell=True,
  2460. input=input_data,
  2461. cwd=cwd,
  2462. env=filter_env,
  2463. capture_output=True,
  2464. )
  2465. if result.returncode != 0:
  2466. filter_error = True
  2467. return None
  2468. return result.stdout
  2469. # Create filter functions based on arguments
  2470. filter_message = None
  2471. if args.msg_filter:
  2472. def filter_message(message):
  2473. result = run_filter(args.msg_filter, input_data=message)
  2474. return result if result is not None else message
  2475. tree_filter = None
  2476. if args.tree_filter:
  2477. def tree_filter(tree_sha, tmpdir):
  2478. from dulwich.objects import Blob, Tree
  2479. # Export tree to tmpdir
  2480. with Repo(".") as r:
  2481. tree = r.object_store[tree_sha]
  2482. for entry in tree.items():
  2483. path = Path(tmpdir) / entry.path.decode()
  2484. if entry.mode & 0o040000: # Directory
  2485. path.mkdir(exist_ok=True)
  2486. else:
  2487. obj = r.object_store[entry.sha]
  2488. path.write_bytes(obj.data)
  2489. # Run the filter command in the temp directory
  2490. run_filter(args.tree_filter, cwd=tmpdir)
  2491. # Rebuild tree from modified temp directory
  2492. def build_tree_from_dir(dir_path):
  2493. tree = Tree()
  2494. for name in sorted(os.listdir(dir_path)):
  2495. if name.startswith("."):
  2496. continue
  2497. path = os.path.join(dir_path, name)
  2498. if os.path.isdir(path):
  2499. subtree_sha = build_tree_from_dir(path)
  2500. tree.add(name.encode(), 0o040000, subtree_sha)
  2501. else:
  2502. with open(path, "rb") as f:
  2503. data = f.read()
  2504. blob = Blob.from_string(data)
  2505. r.object_store.add_object(blob)
  2506. # Use appropriate file mode
  2507. mode = os.stat(path).st_mode
  2508. if mode & 0o100:
  2509. file_mode = 0o100755
  2510. else:
  2511. file_mode = 0o100644
  2512. tree.add(name.encode(), file_mode, blob.id)
  2513. r.object_store.add_object(tree)
  2514. return tree.id
  2515. return build_tree_from_dir(tmpdir)
  2516. index_filter = None
  2517. if args.index_filter:
  2518. def index_filter(tree_sha, index_path):
  2519. run_filter(args.index_filter, extra_env={"GIT_INDEX_FILE": index_path})
  2520. return None # Read back from index
  2521. parent_filter = None
  2522. if args.parent_filter:
  2523. def parent_filter(parents):
  2524. parent_str = " ".join(p.hex() for p in parents)
  2525. result = run_filter(args.parent_filter, input_data=parent_str.encode())
  2526. if result is None:
  2527. return parents
  2528. output = result.decode().strip()
  2529. if not output:
  2530. return []
  2531. new_parents = []
  2532. for sha in output.split():
  2533. if valid_hexsha(sha):
  2534. new_parents.append(sha)
  2535. return new_parents
  2536. commit_filter = None
  2537. if args.commit_filter:
  2538. def commit_filter(commit_obj, tree_sha):
  2539. # The filter receives: tree parent1 parent2...
  2540. cmd_input = tree_sha.hex()
  2541. for parent in commit_obj.parents:
  2542. cmd_input += " " + parent.hex()
  2543. result = run_filter(
  2544. args.commit_filter,
  2545. input_data=cmd_input.encode(),
  2546. extra_env={"GIT_COMMIT": commit_obj.id.hex()},
  2547. )
  2548. if result is None:
  2549. return None
  2550. output = result.decode().strip()
  2551. if not output:
  2552. return None # Skip commit
  2553. if valid_hexsha(output):
  2554. return output
  2555. return None
  2556. tag_name_filter = None
  2557. if args.tag_name_filter:
  2558. def tag_name_filter(tag_name):
  2559. result = run_filter(args.tag_name_filter, input_data=tag_name)
  2560. return result.strip() if result is not None else tag_name
  2561. # Open repo once
  2562. with Repo(".") as r:
  2563. # Check for refs/original if not forcing
  2564. if not args.force:
  2565. original_prefix = args.original.encode() + b"/"
  2566. for ref in r.refs.allkeys():
  2567. if ref.startswith(original_prefix):
  2568. logger.error("Cannot create a new backup.")
  2569. logger.error(
  2570. "A previous backup already exists in %s/", args.original
  2571. )
  2572. logger.error("Force overwriting the backup with -f")
  2573. return 1
  2574. try:
  2575. # Call porcelain.filter_branch with the repo object
  2576. result = porcelain.filter_branch(
  2577. r,
  2578. args.branch,
  2579. filter_message=filter_message,
  2580. tree_filter=tree_filter if args.tree_filter else None,
  2581. index_filter=index_filter if args.index_filter else None,
  2582. parent_filter=parent_filter if args.parent_filter else None,
  2583. commit_filter=commit_filter if args.commit_filter else None,
  2584. subdirectory_filter=args.subdirectory_filter,
  2585. prune_empty=args.prune_empty,
  2586. tag_name_filter=tag_name_filter if args.tag_name_filter else None,
  2587. force=args.force,
  2588. keep_original=True, # Always keep original with git
  2589. )
  2590. # Check if any filter failed
  2591. if filter_error:
  2592. logger.error("Filter command failed")
  2593. return 1
  2594. # Git filter-branch shows progress
  2595. if result:
  2596. logger.info("Rewrite %s (%d commits)", args.branch, len(result))
  2597. # Git shows: Ref 'refs/heads/branch' was rewritten
  2598. if args.branch != "HEAD":
  2599. ref_name = (
  2600. args.branch
  2601. if args.branch.startswith("refs/")
  2602. else f"refs/heads/{args.branch}"
  2603. )
  2604. logger.info("Ref '%s' was rewritten", ref_name)
  2605. return 0
  2606. except porcelain.Error as e:
  2607. logger.error("%s", e)
  2608. return 1
  2609. class cmd_lfs(Command):
  2610. """Git Large File Storage management."""
  2611. """Git LFS management commands."""
  2612. def run(self, argv) -> None:
  2613. """Execute the lfs command.
  2614. Args:
  2615. argv: Command line arguments
  2616. """
  2617. parser = argparse.ArgumentParser(prog="dulwich lfs")
  2618. subparsers = parser.add_subparsers(dest="subcommand", help="LFS subcommands")
  2619. # lfs init
  2620. subparsers.add_parser("init", help="Initialize Git LFS")
  2621. # lfs track
  2622. parser_track = subparsers.add_parser(
  2623. "track", help="Track file patterns with LFS"
  2624. )
  2625. parser_track.add_argument("patterns", nargs="*", help="File patterns to track")
  2626. # lfs untrack
  2627. parser_untrack = subparsers.add_parser(
  2628. "untrack", help="Untrack file patterns from LFS"
  2629. )
  2630. parser_untrack.add_argument(
  2631. "patterns", nargs="+", help="File patterns to untrack"
  2632. )
  2633. # lfs ls-files
  2634. parser_ls = subparsers.add_parser("ls-files", help="List LFS files")
  2635. parser_ls.add_argument("--ref", help="Git ref to check (defaults to HEAD)")
  2636. # lfs migrate
  2637. parser_migrate = subparsers.add_parser("migrate", help="Migrate files to LFS")
  2638. parser_migrate.add_argument("--include", nargs="+", help="Patterns to include")
  2639. parser_migrate.add_argument("--exclude", nargs="+", help="Patterns to exclude")
  2640. parser_migrate.add_argument(
  2641. "--everything", action="store_true", help="Migrate all files above 100MB"
  2642. )
  2643. # lfs pointer
  2644. parser_pointer = subparsers.add_parser("pointer", help="Check LFS pointers")
  2645. parser_pointer.add_argument(
  2646. "--check", nargs="*", dest="paths", help="Check if files are LFS pointers"
  2647. )
  2648. # lfs clean
  2649. parser_clean = subparsers.add_parser("clean", help="Clean file to LFS pointer")
  2650. parser_clean.add_argument("path", help="File path to clean")
  2651. # lfs smudge
  2652. parser_smudge = subparsers.add_parser(
  2653. "smudge", help="Smudge LFS pointer to content"
  2654. )
  2655. parser_smudge.add_argument(
  2656. "--stdin", action="store_true", help="Read pointer from stdin"
  2657. )
  2658. # lfs fetch
  2659. parser_fetch = subparsers.add_parser(
  2660. "fetch", help="Fetch LFS objects from remote"
  2661. )
  2662. parser_fetch.add_argument(
  2663. "--remote", default="origin", help="Remote to fetch from"
  2664. )
  2665. parser_fetch.add_argument("refs", nargs="*", help="Specific refs to fetch")
  2666. # lfs pull
  2667. parser_pull = subparsers.add_parser(
  2668. "pull", help="Pull LFS objects for current checkout"
  2669. )
  2670. parser_pull.add_argument(
  2671. "--remote", default="origin", help="Remote to pull from"
  2672. )
  2673. # lfs push
  2674. parser_push = subparsers.add_parser("push", help="Push LFS objects to remote")
  2675. parser_push.add_argument("--remote", default="origin", help="Remote to push to")
  2676. parser_push.add_argument("refs", nargs="*", help="Specific refs to push")
  2677. # lfs status
  2678. subparsers.add_parser("status", help="Show status of LFS files")
  2679. args = parser.parse_args(argv)
  2680. if args.subcommand == "init":
  2681. porcelain.lfs_init()
  2682. logger.info("Git LFS initialized.")
  2683. elif args.subcommand == "track":
  2684. if args.patterns:
  2685. tracked = porcelain.lfs_track(patterns=args.patterns)
  2686. logger.info("Tracking patterns:")
  2687. else:
  2688. tracked = porcelain.lfs_track()
  2689. logger.info("Currently tracked patterns:")
  2690. for pattern in tracked:
  2691. logger.info(" %s", pattern)
  2692. elif args.subcommand == "untrack":
  2693. tracked = porcelain.lfs_untrack(patterns=args.patterns)
  2694. logger.info("Remaining tracked patterns:")
  2695. for pattern in tracked:
  2696. logger.info(" %s", to_display_str(pattern))
  2697. elif args.subcommand == "ls-files":
  2698. files = porcelain.lfs_ls_files(ref=args.ref)
  2699. for path, oid, size in files:
  2700. logger.info(
  2701. "%s * %s (%s)",
  2702. to_display_str(oid[:12]),
  2703. to_display_str(path),
  2704. format_bytes(size),
  2705. )
  2706. elif args.subcommand == "migrate":
  2707. count = porcelain.lfs_migrate(
  2708. include=args.include, exclude=args.exclude, everything=args.everything
  2709. )
  2710. logger.info("Migrated %d file(s) to Git LFS.", count)
  2711. elif args.subcommand == "pointer":
  2712. if args.paths is not None:
  2713. results = porcelain.lfs_pointer_check(paths=args.paths or None)
  2714. for file_path, pointer in results.items():
  2715. if pointer:
  2716. logger.info(
  2717. "%s: LFS pointer (oid: %s, size: %s)",
  2718. to_display_str(file_path),
  2719. to_display_str(pointer.oid[:12]),
  2720. format_bytes(pointer.size),
  2721. )
  2722. else:
  2723. logger.warning(
  2724. "%s: Not an LFS pointer", to_display_str(file_path)
  2725. )
  2726. elif args.subcommand == "clean":
  2727. pointer = porcelain.lfs_clean(path=args.path)
  2728. sys.stdout.buffer.write(pointer)
  2729. elif args.subcommand == "smudge":
  2730. if args.stdin:
  2731. pointer_content = sys.stdin.buffer.read()
  2732. content = porcelain.lfs_smudge(pointer_content=pointer_content)
  2733. sys.stdout.buffer.write(content)
  2734. else:
  2735. logger.error("--stdin required for smudge command")
  2736. sys.exit(1)
  2737. elif args.subcommand == "fetch":
  2738. refs = args.refs or None
  2739. count = porcelain.lfs_fetch(remote=args.remote, refs=refs)
  2740. logger.info("Fetched %d LFS object(s).", count)
  2741. elif args.subcommand == "pull":
  2742. count = porcelain.lfs_pull(remote=args.remote)
  2743. logger.info("Pulled %d LFS object(s).", count)
  2744. elif args.subcommand == "push":
  2745. refs = args.refs or None
  2746. count = porcelain.lfs_push(remote=args.remote, refs=refs)
  2747. logger.info("Pushed %d LFS object(s).", count)
  2748. elif args.subcommand == "status":
  2749. status = porcelain.lfs_status()
  2750. if status["tracked"]:
  2751. logger.info("LFS tracked files: %d", len(status["tracked"]))
  2752. if status["missing"]:
  2753. logger.warning("\nMissing LFS objects:")
  2754. for file_path in status["missing"]:
  2755. logger.warning(" %s", to_display_str(file_path))
  2756. if status["not_staged"]:
  2757. logger.info("\nModified LFS files not staged:")
  2758. for file_path in status["not_staged"]:
  2759. logger.warning(" %s", to_display_str(file_path))
  2760. if not any(status.values()):
  2761. logger.info("No LFS files found.")
  2762. else:
  2763. parser.print_help()
  2764. sys.exit(1)
  2765. class cmd_help(Command):
  2766. """Display help information about git."""
  2767. def run(self, args) -> None:
  2768. """Execute the help command.
  2769. Args:
  2770. args: Command line arguments
  2771. """
  2772. parser = argparse.ArgumentParser()
  2773. parser.add_argument(
  2774. "-a",
  2775. "--all",
  2776. action="store_true",
  2777. help="List all commands.",
  2778. )
  2779. args = parser.parse_args(args)
  2780. if args.all:
  2781. logger.info("Available commands:")
  2782. for cmd in sorted(commands):
  2783. logger.info(" %s", cmd)
  2784. else:
  2785. logger.info(
  2786. "The dulwich command line tool is currently a very basic frontend for the\n"
  2787. "Dulwich python module. For full functionality, please see the API reference.\n"
  2788. "\n"
  2789. "For a list of supported commands, see 'dulwich help -a'."
  2790. )
  2791. class cmd_format_patch(Command):
  2792. """Prepare patches for e-mail submission."""
  2793. def run(self, args) -> None:
  2794. """Execute the format-patch command.
  2795. Args:
  2796. args: Command line arguments
  2797. """
  2798. parser = argparse.ArgumentParser()
  2799. parser.add_argument(
  2800. "committish",
  2801. nargs="?",
  2802. help="Commit or commit range (e.g., HEAD~3..HEAD or origin/master..HEAD)",
  2803. )
  2804. parser.add_argument(
  2805. "-n",
  2806. "--numbered",
  2807. type=int,
  2808. default=1,
  2809. help="Number of commits to format (default: 1)",
  2810. )
  2811. parser.add_argument(
  2812. "-o",
  2813. "--output-directory",
  2814. dest="outdir",
  2815. help="Output directory for patches",
  2816. )
  2817. parser.add_argument(
  2818. "--stdout",
  2819. action="store_true",
  2820. help="Output patches to stdout",
  2821. )
  2822. args = parser.parse_args(args)
  2823. # Parse committish using the new function
  2824. committish: Optional[Union[bytes, tuple[bytes, bytes]]] = None
  2825. if args.committish:
  2826. with Repo(".") as r:
  2827. range_result = parse_commit_range(r, args.committish)
  2828. if range_result:
  2829. # Convert Commit objects to their SHAs
  2830. committish = (range_result[0].id, range_result[1].id)
  2831. else:
  2832. committish = (
  2833. args.committish.encode()
  2834. if isinstance(args.committish, str)
  2835. else args.committish
  2836. )
  2837. filenames = porcelain.format_patch(
  2838. ".",
  2839. committish=committish,
  2840. outstream=sys.stdout,
  2841. outdir=args.outdir,
  2842. n=args.numbered,
  2843. stdout=args.stdout,
  2844. )
  2845. if not args.stdout:
  2846. for filename in filenames:
  2847. logger.info(filename)
  2848. class cmd_bundle(Command):
  2849. """Create, unpack, and manipulate bundle files."""
  2850. def run(self, args) -> int:
  2851. """Execute the bundle command.
  2852. Args:
  2853. args: Command line arguments
  2854. """
  2855. if not args:
  2856. logger.error("Usage: bundle <create|verify|list-heads|unbundle> <options>")
  2857. return 1
  2858. subcommand = args[0]
  2859. subargs = args[1:]
  2860. if subcommand == "create":
  2861. return self._create(subargs)
  2862. elif subcommand == "verify":
  2863. return self._verify(subargs)
  2864. elif subcommand == "list-heads":
  2865. return self._list_heads(subargs)
  2866. elif subcommand == "unbundle":
  2867. return self._unbundle(subargs)
  2868. else:
  2869. logger.error("Unknown bundle subcommand: %s", subcommand)
  2870. return 1
  2871. def _create(self, args) -> int:
  2872. parser = argparse.ArgumentParser(prog="bundle create")
  2873. parser.add_argument(
  2874. "-q", "--quiet", action="store_true", help="Suppress progress"
  2875. )
  2876. parser.add_argument("--progress", action="store_true", help="Show progress")
  2877. parser.add_argument(
  2878. "--version", type=int, choices=[2, 3], help="Bundle version"
  2879. )
  2880. parser.add_argument("--all", action="store_true", help="Include all refs")
  2881. parser.add_argument("--stdin", action="store_true", help="Read refs from stdin")
  2882. parser.add_argument("file", help="Output bundle file (use - for stdout)")
  2883. parser.add_argument("refs", nargs="*", help="References or rev-list args")
  2884. parsed_args = parser.parse_args(args)
  2885. repo = Repo(".")
  2886. progress = None
  2887. if parsed_args.progress and not parsed_args.quiet:
  2888. def progress(msg: str) -> None:
  2889. logger.error(msg)
  2890. refs_to_include = []
  2891. prerequisites = []
  2892. if parsed_args.all:
  2893. refs_to_include = list(repo.refs.keys())
  2894. elif parsed_args.stdin:
  2895. for line in sys.stdin:
  2896. ref = line.strip().encode("utf-8")
  2897. if ref:
  2898. refs_to_include.append(ref)
  2899. elif parsed_args.refs:
  2900. for ref_arg in parsed_args.refs:
  2901. if ".." in ref_arg:
  2902. range_result = parse_commit_range(repo, ref_arg)
  2903. if range_result:
  2904. start_commit, end_commit = range_result
  2905. prerequisites.append(start_commit.id)
  2906. # For ranges like A..B, we need to include B if it's a ref
  2907. # Split the range to get the end part
  2908. end_part = ref_arg.split("..")[1]
  2909. if end_part: # Not empty (not "A..")
  2910. end_ref = end_part.encode("utf-8")
  2911. if end_ref in repo.refs:
  2912. refs_to_include.append(end_ref)
  2913. else:
  2914. sha = repo.refs[ref_arg.encode("utf-8")]
  2915. refs_to_include.append(ref_arg.encode("utf-8"))
  2916. else:
  2917. if ref_arg.startswith("^"):
  2918. sha = repo.refs[ref_arg[1:].encode("utf-8")]
  2919. prerequisites.append(sha)
  2920. else:
  2921. sha = repo.refs[ref_arg.encode("utf-8")]
  2922. refs_to_include.append(ref_arg.encode("utf-8"))
  2923. else:
  2924. logger.error("No refs specified. Use --all, --stdin, or specify refs")
  2925. return 1
  2926. if not refs_to_include:
  2927. logger.error("fatal: Refusing to create empty bundle.")
  2928. return 1
  2929. bundle = create_bundle_from_repo(
  2930. repo,
  2931. refs=refs_to_include,
  2932. prerequisites=prerequisites,
  2933. version=parsed_args.version,
  2934. progress=progress,
  2935. )
  2936. if parsed_args.file == "-":
  2937. write_bundle(sys.stdout.buffer, bundle)
  2938. else:
  2939. with open(parsed_args.file, "wb") as f:
  2940. write_bundle(f, bundle)
  2941. return 0
  2942. def _verify(self, args) -> int:
  2943. parser = argparse.ArgumentParser(prog="bundle verify")
  2944. parser.add_argument(
  2945. "-q", "--quiet", action="store_true", help="Suppress output"
  2946. )
  2947. parser.add_argument("file", help="Bundle file to verify (use - for stdin)")
  2948. parsed_args = parser.parse_args(args)
  2949. repo = Repo(".")
  2950. def verify_bundle(bundle):
  2951. missing_prereqs = []
  2952. for prereq_sha, comment in bundle.prerequisites:
  2953. try:
  2954. repo.object_store[prereq_sha]
  2955. except KeyError:
  2956. missing_prereqs.append(prereq_sha)
  2957. if missing_prereqs:
  2958. if not parsed_args.quiet:
  2959. logger.info("The bundle requires these prerequisite commits:")
  2960. for sha in missing_prereqs:
  2961. logger.info(" %s", sha.decode())
  2962. return 1
  2963. else:
  2964. if not parsed_args.quiet:
  2965. logger.info(
  2966. "The bundle is valid and can be applied to the current repository"
  2967. )
  2968. return 0
  2969. if parsed_args.file == "-":
  2970. bundle = read_bundle(sys.stdin.buffer)
  2971. return verify_bundle(bundle)
  2972. else:
  2973. with open(parsed_args.file, "rb") as f:
  2974. bundle = read_bundle(f)
  2975. return verify_bundle(bundle)
  2976. def _list_heads(self, args) -> int:
  2977. parser = argparse.ArgumentParser(prog="bundle list-heads")
  2978. parser.add_argument("file", help="Bundle file (use - for stdin)")
  2979. parser.add_argument("refnames", nargs="*", help="Only show these refs")
  2980. parsed_args = parser.parse_args(args)
  2981. def list_heads(bundle):
  2982. for ref, sha in bundle.references.items():
  2983. if not parsed_args.refnames or ref.decode() in parsed_args.refnames:
  2984. logger.info("%s %s", sha.decode(), ref.decode())
  2985. if parsed_args.file == "-":
  2986. bundle = read_bundle(sys.stdin.buffer)
  2987. list_heads(bundle)
  2988. else:
  2989. with open(parsed_args.file, "rb") as f:
  2990. bundle = read_bundle(f)
  2991. list_heads(bundle)
  2992. return 0
  2993. def _unbundle(self, args) -> int:
  2994. parser = argparse.ArgumentParser(prog="bundle unbundle")
  2995. parser.add_argument("--progress", action="store_true", help="Show progress")
  2996. parser.add_argument("file", help="Bundle file (use - for stdin)")
  2997. parser.add_argument("refnames", nargs="*", help="Only unbundle these refs")
  2998. parsed_args = parser.parse_args(args)
  2999. repo = Repo(".")
  3000. progress = None
  3001. if parsed_args.progress:
  3002. def progress(msg: str) -> None:
  3003. logger.error(msg)
  3004. if parsed_args.file == "-":
  3005. bundle = read_bundle(sys.stdin.buffer)
  3006. # Process the bundle while file is still available via stdin
  3007. bundle.store_objects(repo.object_store, progress=progress)
  3008. else:
  3009. # Keep the file open during bundle processing
  3010. with open(parsed_args.file, "rb") as f:
  3011. bundle = read_bundle(f)
  3012. # Process pack data while file is still open
  3013. bundle.store_objects(repo.object_store, progress=progress)
  3014. for ref, sha in bundle.references.items():
  3015. if not parsed_args.refnames or ref.decode() in parsed_args.refnames:
  3016. logger.info(ref.decode())
  3017. return 0
  3018. class cmd_worktree_add(Command):
  3019. """Create a new worktree."""
  3020. """Add a new worktree to the repository."""
  3021. def run(self, args) -> Optional[int]:
  3022. """Execute the worktree-add command.
  3023. Args:
  3024. args: Command line arguments
  3025. """
  3026. parser = argparse.ArgumentParser(
  3027. description="Add a new worktree", prog="dulwich worktree add"
  3028. )
  3029. parser.add_argument("path", help="Path for the new worktree")
  3030. parser.add_argument("committish", nargs="?", help="Commit-ish to checkout")
  3031. parser.add_argument("-b", "--create-branch", help="Create a new branch")
  3032. parser.add_argument(
  3033. "-B", "--force-create-branch", help="Create or reset a branch"
  3034. )
  3035. parser.add_argument(
  3036. "--detach", action="store_true", help="Detach HEAD in new worktree"
  3037. )
  3038. parser.add_argument("--force", action="store_true", help="Force creation")
  3039. parsed_args = parser.parse_args(args)
  3040. from dulwich import porcelain
  3041. branch = None
  3042. commit = None
  3043. if parsed_args.create_branch or parsed_args.force_create_branch:
  3044. branch = (
  3045. parsed_args.create_branch or parsed_args.force_create_branch
  3046. ).encode()
  3047. elif parsed_args.committish and not parsed_args.detach:
  3048. # If committish is provided and not detaching, treat as branch
  3049. branch = parsed_args.committish.encode()
  3050. elif parsed_args.committish:
  3051. # If committish is provided and detaching, treat as commit
  3052. commit = parsed_args.committish.encode()
  3053. worktree_path = porcelain.worktree_add(
  3054. repo=".",
  3055. path=parsed_args.path,
  3056. branch=branch,
  3057. commit=commit,
  3058. detach=parsed_args.detach,
  3059. force=parsed_args.force or bool(parsed_args.force_create_branch),
  3060. )
  3061. logger.info("Worktree added: %s", worktree_path)
  3062. return 0
  3063. class cmd_worktree_list(Command):
  3064. """List worktrees."""
  3065. """List details of each worktree."""
  3066. def run(self, args) -> Optional[int]:
  3067. """Execute the worktree-list command.
  3068. Args:
  3069. args: Command line arguments
  3070. """
  3071. parser = argparse.ArgumentParser(
  3072. description="List worktrees", prog="dulwich worktree list"
  3073. )
  3074. parser.add_argument(
  3075. "-v", "--verbose", action="store_true", help="Show additional information"
  3076. )
  3077. parser.add_argument(
  3078. "--porcelain", action="store_true", help="Machine-readable output"
  3079. )
  3080. parsed_args = parser.parse_args(args)
  3081. from dulwich import porcelain
  3082. worktrees = porcelain.worktree_list(repo=".")
  3083. for wt in worktrees:
  3084. path = wt.path
  3085. if wt.bare:
  3086. status = "(bare)"
  3087. elif wt.detached:
  3088. status = (
  3089. f"(detached HEAD {wt.head[:7].decode() if wt.head else 'unknown'})"
  3090. )
  3091. elif wt.branch:
  3092. branch_name = wt.branch.decode().replace("refs/heads/", "")
  3093. status = f"[{branch_name}]"
  3094. else:
  3095. status = "(unknown)"
  3096. if parsed_args.porcelain:
  3097. locked = "locked" if wt.locked else "unlocked"
  3098. prunable = "prunable" if wt.prunable else "unprunable"
  3099. logger.info(
  3100. "%s %s %s %s %s",
  3101. path,
  3102. wt.head.decode() if wt.head else "unknown",
  3103. status,
  3104. locked,
  3105. prunable,
  3106. )
  3107. else:
  3108. line = f"{path} {status}"
  3109. if wt.locked:
  3110. line += " locked"
  3111. if wt.prunable:
  3112. line += " prunable"
  3113. logger.info(line)
  3114. return 0
  3115. class cmd_worktree_remove(Command):
  3116. """Remove a worktree."""
  3117. """Remove a worktree."""
  3118. def run(self, args) -> Optional[int]:
  3119. """Execute the worktree-remove command.
  3120. Args:
  3121. args: Command line arguments
  3122. """
  3123. parser = argparse.ArgumentParser(
  3124. description="Remove a worktree", prog="dulwich worktree remove"
  3125. )
  3126. parser.add_argument("worktree", help="Path to worktree to remove")
  3127. parser.add_argument("--force", action="store_true", help="Force removal")
  3128. parsed_args = parser.parse_args(args)
  3129. from dulwich import porcelain
  3130. porcelain.worktree_remove(
  3131. repo=".", path=parsed_args.worktree, force=parsed_args.force
  3132. )
  3133. logger.info("Worktree removed: %s", parsed_args.worktree)
  3134. return 0
  3135. class cmd_worktree_prune(Command):
  3136. """Prune worktree information."""
  3137. """Prune worktree information."""
  3138. def run(self, args) -> Optional[int]:
  3139. """Execute the worktree-prune command.
  3140. Args:
  3141. args: Command line arguments
  3142. """
  3143. parser = argparse.ArgumentParser(
  3144. description="Prune worktree information", prog="dulwich worktree prune"
  3145. )
  3146. parser.add_argument(
  3147. "--dry-run", action="store_true", help="Do not remove anything"
  3148. )
  3149. parser.add_argument(
  3150. "-v", "--verbose", action="store_true", help="Report all removals"
  3151. )
  3152. parser.add_argument(
  3153. "--expire", type=int, help="Expire worktrees older than time (seconds)"
  3154. )
  3155. parsed_args = parser.parse_args(args)
  3156. from dulwich import porcelain
  3157. pruned = porcelain.worktree_prune(
  3158. repo=".", dry_run=parsed_args.dry_run, expire=parsed_args.expire
  3159. )
  3160. if pruned:
  3161. if parsed_args.dry_run:
  3162. logger.info("Would prune worktrees:")
  3163. elif parsed_args.verbose:
  3164. logger.info("Pruned worktrees:")
  3165. for wt_id in pruned:
  3166. logger.info(" %s", wt_id)
  3167. elif parsed_args.verbose:
  3168. logger.info("No worktrees to prune")
  3169. return 0
  3170. class cmd_worktree_lock(Command):
  3171. """Lock a worktree to prevent it from being pruned."""
  3172. """Lock a worktree."""
  3173. def run(self, args) -> Optional[int]:
  3174. """Execute the worktree-lock command.
  3175. Args:
  3176. args: Command line arguments
  3177. """
  3178. parser = argparse.ArgumentParser(
  3179. description="Lock a worktree", prog="dulwich worktree lock"
  3180. )
  3181. parser.add_argument("worktree", help="Path to worktree to lock")
  3182. parser.add_argument("--reason", help="Reason for locking")
  3183. parsed_args = parser.parse_args(args)
  3184. from dulwich import porcelain
  3185. porcelain.worktree_lock(
  3186. repo=".", path=parsed_args.worktree, reason=parsed_args.reason
  3187. )
  3188. logger.info("Worktree locked: %s", parsed_args.worktree)
  3189. return 0
  3190. class cmd_worktree_unlock(Command):
  3191. """Unlock a locked worktree."""
  3192. """Unlock a worktree."""
  3193. def run(self, args) -> Optional[int]:
  3194. """Execute the worktree-unlock command.
  3195. Args:
  3196. args: Command line arguments
  3197. """
  3198. parser = argparse.ArgumentParser(
  3199. description="Unlock a worktree", prog="dulwich worktree unlock"
  3200. )
  3201. parser.add_argument("worktree", help="Path to worktree to unlock")
  3202. parsed_args = parser.parse_args(args)
  3203. from dulwich import porcelain
  3204. porcelain.worktree_unlock(repo=".", path=parsed_args.worktree)
  3205. logger.info("Worktree unlocked: %s", parsed_args.worktree)
  3206. return 0
  3207. class cmd_worktree_move(Command):
  3208. """Move a worktree to a new location."""
  3209. """Move a worktree."""
  3210. def run(self, args) -> Optional[int]:
  3211. """Execute the worktree-move command.
  3212. Args:
  3213. args: Command line arguments
  3214. """
  3215. parser = argparse.ArgumentParser(
  3216. description="Move a worktree", prog="dulwich worktree move"
  3217. )
  3218. parser.add_argument("worktree", help="Path to worktree to move")
  3219. parser.add_argument("new_path", help="New path for the worktree")
  3220. parsed_args = parser.parse_args(args)
  3221. from dulwich import porcelain
  3222. porcelain.worktree_move(
  3223. repo=".", old_path=parsed_args.worktree, new_path=parsed_args.new_path
  3224. )
  3225. logger.info(
  3226. "Worktree moved: %s -> %s", parsed_args.worktree, parsed_args.new_path
  3227. )
  3228. return 0
  3229. class cmd_worktree(SuperCommand):
  3230. """Manage multiple working trees."""
  3231. """Manage multiple working trees."""
  3232. subcommands: ClassVar[dict[str, type[Command]]] = {
  3233. "add": cmd_worktree_add,
  3234. "list": cmd_worktree_list,
  3235. "remove": cmd_worktree_remove,
  3236. "prune": cmd_worktree_prune,
  3237. "lock": cmd_worktree_lock,
  3238. "unlock": cmd_worktree_unlock,
  3239. "move": cmd_worktree_move,
  3240. }
  3241. default_command = cmd_worktree_list
  3242. commands = {
  3243. "add": cmd_add,
  3244. "annotate": cmd_annotate,
  3245. "archive": cmd_archive,
  3246. "bisect": cmd_bisect,
  3247. "blame": cmd_blame,
  3248. "branch": cmd_branch,
  3249. "bundle": cmd_bundle,
  3250. "check-ignore": cmd_check_ignore,
  3251. "check-mailmap": cmd_check_mailmap,
  3252. "checkout": cmd_checkout,
  3253. "cherry-pick": cmd_cherry_pick,
  3254. "clone": cmd_clone,
  3255. "commit": cmd_commit,
  3256. "commit-tree": cmd_commit_tree,
  3257. "count-objects": cmd_count_objects,
  3258. "describe": cmd_describe,
  3259. "daemon": cmd_daemon,
  3260. "diff": cmd_diff,
  3261. "diff-tree": cmd_diff_tree,
  3262. "dump-pack": cmd_dump_pack,
  3263. "dump-index": cmd_dump_index,
  3264. "fetch-pack": cmd_fetch_pack,
  3265. "fetch": cmd_fetch,
  3266. "filter-branch": cmd_filter_branch,
  3267. "for-each-ref": cmd_for_each_ref,
  3268. "format-patch": cmd_format_patch,
  3269. "fsck": cmd_fsck,
  3270. "gc": cmd_gc,
  3271. "help": cmd_help,
  3272. "init": cmd_init,
  3273. "lfs": cmd_lfs,
  3274. "log": cmd_log,
  3275. "ls-files": cmd_ls_files,
  3276. "ls-remote": cmd_ls_remote,
  3277. "ls-tree": cmd_ls_tree,
  3278. "merge": cmd_merge,
  3279. "merge-tree": cmd_merge_tree,
  3280. "notes": cmd_notes,
  3281. "pack-objects": cmd_pack_objects,
  3282. "pack-refs": cmd_pack_refs,
  3283. "prune": cmd_prune,
  3284. "pull": cmd_pull,
  3285. "push": cmd_push,
  3286. "rebase": cmd_rebase,
  3287. "receive-pack": cmd_receive_pack,
  3288. "reflog": cmd_reflog,
  3289. "remote": cmd_remote,
  3290. "repack": cmd_repack,
  3291. "reset": cmd_reset,
  3292. "revert": cmd_revert,
  3293. "rev-list": cmd_rev_list,
  3294. "rm": cmd_rm,
  3295. "mv": cmd_mv,
  3296. "show": cmd_show,
  3297. "stash": cmd_stash,
  3298. "status": cmd_status,
  3299. "shortlog": cmd_shortlog,
  3300. "symbolic-ref": cmd_symbolic_ref,
  3301. "submodule": cmd_submodule,
  3302. "tag": cmd_tag,
  3303. "unpack-objects": cmd_unpack_objects,
  3304. "update-server-info": cmd_update_server_info,
  3305. "upload-pack": cmd_upload_pack,
  3306. "web-daemon": cmd_web_daemon,
  3307. "worktree": cmd_worktree,
  3308. "write-tree": cmd_write_tree,
  3309. }
  3310. def main(argv=None) -> Optional[int]:
  3311. """Main entry point for the Dulwich CLI.
  3312. Args:
  3313. argv: Command line arguments (defaults to sys.argv[1:])
  3314. Returns:
  3315. Exit code or None
  3316. """
  3317. if argv is None:
  3318. argv = sys.argv[1:]
  3319. # Parse only the global options and command, stop at first positional
  3320. parser = argparse.ArgumentParser(
  3321. prog="dulwich",
  3322. description="Simple command-line interface to Dulwich",
  3323. add_help=False, # We'll handle help ourselves
  3324. )
  3325. parser.add_argument("--no-pager", action="store_true", help="Disable pager")
  3326. parser.add_argument("--pager", action="store_true", help="Force enable pager")
  3327. parser.add_argument("--help", "-h", action="store_true", help="Show help")
  3328. # Parse known args to separate global options from command args
  3329. global_args, remaining = parser.parse_known_args(argv)
  3330. # Apply global pager settings
  3331. if global_args.no_pager:
  3332. disable_pager()
  3333. elif global_args.pager:
  3334. enable_pager()
  3335. # Handle help
  3336. if global_args.help or not remaining:
  3337. parser = argparse.ArgumentParser(
  3338. prog="dulwich", description="Simple command-line interface to Dulwich"
  3339. )
  3340. parser.add_argument("--no-pager", action="store_true", help="Disable pager")
  3341. parser.add_argument("--pager", action="store_true", help="Force enable pager")
  3342. parser.add_argument(
  3343. "command",
  3344. nargs="?",
  3345. help=f"Command to run. Available: {', '.join(sorted(commands.keys()))}",
  3346. )
  3347. parser.print_help()
  3348. return 1
  3349. logging.basicConfig(
  3350. level=logging.INFO,
  3351. format="%(message)s",
  3352. )
  3353. # First remaining arg is the command
  3354. cmd = remaining[0]
  3355. cmd_args = remaining[1:]
  3356. try:
  3357. cmd_kls = commands[cmd]
  3358. except KeyError:
  3359. logging.fatal("No such subcommand: %s", cmd)
  3360. return 1
  3361. # TODO(jelmer): Return non-0 on errors
  3362. return cmd_kls().run(cmd_args)
  3363. def _main() -> None:
  3364. if "DULWICH_PDB" in os.environ and getattr(signal, "SIGQUIT", None):
  3365. signal.signal(signal.SIGQUIT, signal_quit) # type: ignore
  3366. signal.signal(signal.SIGINT, signal_int)
  3367. sys.exit(main())
  3368. if __name__ == "__main__":
  3369. _main()