cli.py 100 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882288328842885288628872888288928902891289228932894289528962897289828992900290129022903290429052906290729082909291029112912291329142915291629172918291929202921292229232924292529262927292829292930293129322933293429352936293729382939294029412942294329442945294629472948294929502951295229532954295529562957295829592960296129622963296429652966296729682969297029712972297329742975297629772978297929802981298229832984298529862987298829892990299129922993299429952996299729982999300030013002300330043005300630073008300930103011
  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 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 valid_hexsha
  44. from .objectspec import parse_commit_range
  45. from .pack import Pack, sha_to_hex
  46. from .repo import Repo
  47. class CommitMessageError(Exception):
  48. """Raised when there's an issue with the commit message."""
  49. def signal_int(signal, frame) -> None:
  50. sys.exit(1)
  51. def signal_quit(signal, frame) -> None:
  52. import pdb
  53. pdb.set_trace()
  54. def parse_relative_time(time_str):
  55. """Parse a relative time string like '2 weeks ago' into seconds.
  56. Args:
  57. time_str: String like '2 weeks ago' or 'now'
  58. Returns:
  59. Number of seconds
  60. Raises:
  61. ValueError: If the time string cannot be parsed
  62. """
  63. if time_str == "now":
  64. return 0
  65. if not time_str.endswith(" ago"):
  66. raise ValueError(f"Invalid relative time format: {time_str}")
  67. parts = time_str[:-4].split()
  68. if len(parts) != 2:
  69. raise ValueError(f"Invalid relative time format: {time_str}")
  70. try:
  71. num = int(parts[0])
  72. unit = parts[1]
  73. multipliers = {
  74. "second": 1,
  75. "seconds": 1,
  76. "minute": 60,
  77. "minutes": 60,
  78. "hour": 3600,
  79. "hours": 3600,
  80. "day": 86400,
  81. "days": 86400,
  82. "week": 604800,
  83. "weeks": 604800,
  84. }
  85. if unit in multipliers:
  86. return num * multipliers[unit]
  87. else:
  88. raise ValueError(f"Unknown time unit: {unit}")
  89. except ValueError as e:
  90. if "invalid literal" in str(e):
  91. raise ValueError(f"Invalid number in relative time: {parts[0]}")
  92. raise
  93. def format_bytes(bytes):
  94. """Format bytes as human-readable string.
  95. Args:
  96. bytes: Number of bytes
  97. Returns:
  98. Human-readable string like "1.5 MB"
  99. """
  100. for unit in ["B", "KB", "MB", "GB"]:
  101. if bytes < 1024.0:
  102. return f"{bytes:.1f} {unit}"
  103. bytes /= 1024.0
  104. return f"{bytes:.1f} TB"
  105. def launch_editor(template_content=b""):
  106. """Launch an editor for the user to enter text.
  107. Args:
  108. template_content: Initial content for the editor
  109. Returns:
  110. The edited content as bytes
  111. """
  112. # Determine which editor to use
  113. editor = os.environ.get("GIT_EDITOR") or os.environ.get("EDITOR") or "vi"
  114. # Create a temporary file
  115. with tempfile.NamedTemporaryFile(mode="wb", delete=False, suffix=".txt") as f:
  116. temp_file = f.name
  117. f.write(template_content)
  118. try:
  119. # Launch the editor
  120. subprocess.run([editor, temp_file], check=True)
  121. # Read the edited content
  122. with open(temp_file, "rb") as f:
  123. content = f.read()
  124. return content
  125. finally:
  126. # Clean up the temporary file
  127. os.unlink(temp_file)
  128. class PagerBuffer:
  129. """Binary buffer wrapper for Pager to mimic sys.stdout.buffer."""
  130. def __init__(self, pager):
  131. self.pager = pager
  132. def write(self, data: bytes):
  133. """Write bytes to pager."""
  134. if isinstance(data, bytes):
  135. text = data.decode("utf-8", errors="replace")
  136. return self.pager.write(text)
  137. return self.pager.write(data)
  138. def flush(self):
  139. """Flush the pager."""
  140. return self.pager.flush()
  141. def writelines(self, lines):
  142. """Write multiple lines to pager."""
  143. for line in lines:
  144. self.write(line)
  145. def readable(self):
  146. """Return whether the buffer is readable (it's not)."""
  147. return False
  148. def writable(self):
  149. """Return whether the buffer is writable."""
  150. return not self.pager._closed
  151. def seekable(self):
  152. """Return whether the buffer is seekable (it's not)."""
  153. return False
  154. def close(self):
  155. """Close the pager."""
  156. return self.pager.close()
  157. @property
  158. def closed(self):
  159. """Return whether the buffer is closed."""
  160. return self.pager.closed
  161. class Pager:
  162. """File-like object that pages output through external pager programs."""
  163. def __init__(self, pager_cmd="cat"):
  164. self.pager_process = None
  165. self.buffer = PagerBuffer(self)
  166. self._closed = False
  167. self.pager_cmd = pager_cmd
  168. self._pager_died = False
  169. def _get_pager_command(self) -> str:
  170. """Get the pager command to use."""
  171. return self.pager_cmd
  172. def _ensure_pager_started(self):
  173. """Start the pager process if not already started."""
  174. if self.pager_process is None and not self._closed:
  175. try:
  176. pager_cmd = self._get_pager_command()
  177. self.pager_process = subprocess.Popen(
  178. pager_cmd,
  179. shell=True,
  180. stdin=subprocess.PIPE,
  181. stdout=sys.stdout,
  182. stderr=sys.stderr,
  183. text=True,
  184. )
  185. except (OSError, subprocess.SubprocessError):
  186. # Pager failed to start, fall back to direct output
  187. self.pager_process = None
  188. def write(self, text: str) -> int:
  189. """Write text to the pager."""
  190. if self._closed:
  191. raise ValueError("I/O operation on closed file")
  192. # If pager died (user quit), stop writing output
  193. if self._pager_died:
  194. return len(text)
  195. self._ensure_pager_started()
  196. if self.pager_process and self.pager_process.stdin:
  197. try:
  198. return self.pager_process.stdin.write(text)
  199. except (OSError, subprocess.SubprocessError, BrokenPipeError):
  200. # Pager died (user quit), stop writing output
  201. self._pager_died = True
  202. return len(text)
  203. else:
  204. # No pager available, write directly to stdout
  205. return sys.stdout.write(text)
  206. def flush(self):
  207. """Flush the pager."""
  208. if self._closed or self._pager_died:
  209. return
  210. if self.pager_process and self.pager_process.stdin:
  211. try:
  212. self.pager_process.stdin.flush()
  213. except (OSError, subprocess.SubprocessError, BrokenPipeError):
  214. self._pager_died = True
  215. else:
  216. sys.stdout.flush()
  217. def close(self):
  218. """Close the pager."""
  219. if self._closed:
  220. return
  221. self._closed = True
  222. if self.pager_process:
  223. try:
  224. if self.pager_process.stdin:
  225. self.pager_process.stdin.close()
  226. self.pager_process.wait()
  227. except (OSError, subprocess.SubprocessError):
  228. pass
  229. self.pager_process = None
  230. def __enter__(self):
  231. """Context manager entry."""
  232. return self
  233. def __exit__(self, exc_type, exc_val, exc_tb):
  234. """Context manager exit."""
  235. self.close()
  236. # Additional file-like methods for compatibility
  237. def writelines(self, lines):
  238. """Write a list of lines to the pager."""
  239. if self._pager_died:
  240. return
  241. for line in lines:
  242. self.write(line)
  243. @property
  244. def closed(self):
  245. """Return whether the pager is closed."""
  246. return self._closed
  247. def readable(self):
  248. """Return whether the pager is readable (it's not)."""
  249. return False
  250. def writable(self):
  251. """Return whether the pager is writable."""
  252. return not self._closed
  253. def seekable(self):
  254. """Return whether the pager is seekable (it's not)."""
  255. return False
  256. class _StreamContextAdapter:
  257. """Adapter to make streams work with context manager protocol."""
  258. def __init__(self, stream):
  259. self.stream = stream
  260. # Expose buffer if it exists
  261. if hasattr(stream, "buffer"):
  262. self.buffer = stream.buffer
  263. else:
  264. self.buffer = stream
  265. def __enter__(self):
  266. return self.stream
  267. def __exit__(self, exc_type, exc_val, exc_tb):
  268. # For stdout/stderr, we don't close them
  269. pass
  270. def __getattr__(self, name):
  271. return getattr(self.stream, name)
  272. def get_pager(config=None, cmd_name=None):
  273. """Get a pager instance if paging should be used, otherwise return sys.stdout.
  274. Args:
  275. config: Optional config instance (e.g., StackedConfig) to read settings from
  276. cmd_name: Optional command name for per-command pager settings
  277. Returns:
  278. Either a wrapped sys.stdout or a Pager instance (both context managers)
  279. """
  280. # Check global pager disable flag
  281. if getattr(get_pager, "_disabled", False):
  282. return _StreamContextAdapter(sys.stdout)
  283. # Don't page if stdout is not a terminal
  284. if not sys.stdout.isatty():
  285. return _StreamContextAdapter(sys.stdout)
  286. # Priority order for pager command (following git's behavior):
  287. # 1. Check pager.<cmd> config (if cmd_name provided)
  288. # 2. Check environment variables: DULWICH_PAGER, GIT_PAGER, PAGER
  289. # 3. Check core.pager config
  290. # 4. Fallback to common pagers
  291. pager_cmd = None
  292. # 1. Check per-command pager config (pager.<cmd>)
  293. if config and cmd_name:
  294. try:
  295. pager_value = config.get(
  296. ("pager",), cmd_name.encode() if isinstance(cmd_name, str) else cmd_name
  297. )
  298. except KeyError:
  299. pass
  300. else:
  301. if pager_value == b"false":
  302. return _StreamContextAdapter(sys.stdout)
  303. elif pager_value != b"true":
  304. # It's a custom pager command
  305. pager_cmd = (
  306. pager_value.decode()
  307. if isinstance(pager_value, bytes)
  308. else pager_value
  309. )
  310. # 2. Check environment variables
  311. if not pager_cmd:
  312. for env_var in ["DULWICH_PAGER", "GIT_PAGER", "PAGER"]:
  313. pager = os.environ.get(env_var)
  314. if pager:
  315. if pager == "false":
  316. return _StreamContextAdapter(sys.stdout)
  317. pager_cmd = pager
  318. break
  319. # 3. Check core.pager config
  320. if not pager_cmd and config:
  321. try:
  322. core_pager = config.get(("core",), b"pager")
  323. except KeyError:
  324. pass
  325. else:
  326. if core_pager == b"false" or core_pager == b"":
  327. return _StreamContextAdapter(sys.stdout)
  328. pager_cmd = (
  329. core_pager.decode() if isinstance(core_pager, bytes) else core_pager
  330. )
  331. # 4. Fallback to common pagers
  332. if not pager_cmd:
  333. for pager in ["less", "more", "cat"]:
  334. if shutil.which(pager):
  335. if pager == "less":
  336. pager_cmd = "less -FRX" # -F: quit if one screen, -R: raw control chars, -X: no init/deinit
  337. else:
  338. pager_cmd = pager
  339. break
  340. else:
  341. pager_cmd = "cat" # Ultimate fallback
  342. return Pager(pager_cmd)
  343. def disable_pager():
  344. """Disable pager for this session."""
  345. get_pager._disabled = True
  346. def enable_pager():
  347. """Enable pager for this session."""
  348. get_pager._disabled = False
  349. class Command:
  350. """A Dulwich subcommand."""
  351. def run(self, args) -> Optional[int]:
  352. """Run the command."""
  353. raise NotImplementedError(self.run)
  354. class cmd_archive(Command):
  355. def run(self, args) -> None:
  356. parser = argparse.ArgumentParser()
  357. parser.add_argument(
  358. "--remote",
  359. type=str,
  360. help="Retrieve archive from specified remote repo",
  361. )
  362. parser.add_argument("committish", type=str, nargs="?")
  363. args = parser.parse_args(args)
  364. if args.remote:
  365. client, path = get_transport_and_path(args.remote)
  366. client.archive(
  367. path,
  368. args.committish,
  369. sys.stdout.write,
  370. write_error=sys.stderr.write,
  371. )
  372. else:
  373. # Use buffer if available (for binary output), otherwise use stdout
  374. outstream = getattr(sys.stdout, "buffer", sys.stdout)
  375. porcelain.archive(
  376. ".", args.committish, outstream=outstream, errstream=sys.stderr
  377. )
  378. class cmd_add(Command):
  379. def run(self, argv) -> None:
  380. parser = argparse.ArgumentParser()
  381. parser.add_argument("path", nargs="+")
  382. args = parser.parse_args(argv)
  383. # Convert '.' to None to add all files
  384. paths = args.path
  385. if len(paths) == 1 and paths[0] == ".":
  386. paths = None
  387. porcelain.add(".", paths=paths)
  388. class cmd_annotate(Command):
  389. def run(self, argv) -> None:
  390. parser = argparse.ArgumentParser()
  391. parser.add_argument("path", help="Path to file to annotate")
  392. parser.add_argument("committish", nargs="?", help="Commit to start from")
  393. args = parser.parse_args(argv)
  394. with Repo(".") as repo:
  395. config = repo.get_config_stack()
  396. with get_pager(config=config, cmd_name="annotate") as outstream:
  397. results = porcelain.annotate(repo, args.path, args.committish)
  398. for (commit, entry), line in results:
  399. # Show shortened commit hash and line content
  400. commit_hash = commit.id[:8]
  401. outstream.write(f"{commit_hash.decode()} {line.decode()}\n")
  402. class cmd_blame(Command):
  403. def run(self, argv) -> None:
  404. # blame is an alias for annotate
  405. cmd_annotate().run(argv)
  406. class cmd_rm(Command):
  407. def run(self, argv) -> None:
  408. parser = argparse.ArgumentParser()
  409. parser.add_argument(
  410. "--cached", action="store_true", help="Remove from index only"
  411. )
  412. parser.add_argument("path", type=Path, nargs="+")
  413. args = parser.parse_args(argv)
  414. porcelain.remove(".", paths=args.path, cached=args.cached)
  415. class cmd_mv(Command):
  416. def run(self, argv) -> None:
  417. parser = argparse.ArgumentParser()
  418. parser.add_argument(
  419. "-f",
  420. "--force",
  421. action="store_true",
  422. help="Force move even if destination exists",
  423. )
  424. parser.add_argument("source", type=Path)
  425. parser.add_argument("destination", type=Path)
  426. args = parser.parse_args(argv)
  427. porcelain.mv(".", args.source, args.destination, force=args.force)
  428. class cmd_fetch_pack(Command):
  429. def run(self, argv) -> None:
  430. parser = argparse.ArgumentParser()
  431. parser.add_argument("--all", action="store_true")
  432. parser.add_argument("location", nargs="?", type=str)
  433. parser.add_argument("refs", nargs="*", type=str)
  434. args = parser.parse_args(argv)
  435. client, path = get_transport_and_path(args.location)
  436. r = Repo(".")
  437. if args.all:
  438. determine_wants = r.object_store.determine_wants_all
  439. else:
  440. def determine_wants(refs, depth: Optional[int] = None):
  441. return [y.encode("utf-8") for y in args.refs if y not in r.object_store]
  442. client.fetch(path, r, determine_wants)
  443. class cmd_fetch(Command):
  444. def run(self, args) -> None:
  445. parser = argparse.ArgumentParser()
  446. parser.add_argument("location", help="Remote location to fetch from")
  447. args = parser.parse_args(args)
  448. client, path = get_transport_and_path(args.location)
  449. r = Repo(".")
  450. def progress(msg: bytes) -> None:
  451. sys.stdout.buffer.write(msg)
  452. refs = client.fetch(path, r, progress=progress)
  453. print("Remote refs:")
  454. for item in refs.items():
  455. print("{} -> {}".format(*item))
  456. class cmd_for_each_ref(Command):
  457. def run(self, args) -> None:
  458. parser = argparse.ArgumentParser()
  459. parser.add_argument("pattern", type=str, nargs="?")
  460. args = parser.parse_args(args)
  461. for sha, object_type, ref in porcelain.for_each_ref(".", args.pattern):
  462. print(f"{sha.decode()} {object_type.decode()}\t{ref.decode()}")
  463. class cmd_fsck(Command):
  464. def run(self, args) -> None:
  465. parser = argparse.ArgumentParser()
  466. parser.parse_args(args)
  467. for obj, msg in porcelain.fsck("."):
  468. print(f"{obj}: {msg}")
  469. class cmd_log(Command):
  470. def run(self, args) -> None:
  471. parser = argparse.ArgumentParser()
  472. parser.add_argument(
  473. "--reverse",
  474. action="store_true",
  475. help="Reverse order in which entries are printed",
  476. )
  477. parser.add_argument(
  478. "--name-status",
  479. action="store_true",
  480. help="Print name/status for each changed file",
  481. )
  482. parser.add_argument("paths", nargs="*", help="Paths to show log for")
  483. args = parser.parse_args(args)
  484. with Repo(".") as repo:
  485. config = repo.get_config_stack()
  486. with get_pager(config=config, cmd_name="log") as outstream:
  487. porcelain.log(
  488. repo,
  489. paths=args.paths,
  490. reverse=args.reverse,
  491. name_status=args.name_status,
  492. outstream=outstream,
  493. )
  494. class cmd_diff(Command):
  495. def run(self, args) -> None:
  496. parser = argparse.ArgumentParser()
  497. parser.add_argument(
  498. "committish", nargs="*", default=[], help="Commits or refs to compare"
  499. )
  500. parser.add_argument("--staged", action="store_true", help="Show staged changes")
  501. parser.add_argument(
  502. "--cached",
  503. action="store_true",
  504. help="Show staged changes (same as --staged)",
  505. )
  506. parser.add_argument(
  507. "--color",
  508. choices=["always", "never", "auto"],
  509. default="auto",
  510. help="Use colored output (requires pygments)",
  511. )
  512. parser.add_argument(
  513. "--", dest="separator", action="store_true", help=argparse.SUPPRESS
  514. )
  515. parser.add_argument("paths", nargs="*", default=[], help="Paths to limit diff")
  516. # Handle the -- separator for paths
  517. if "--" in args:
  518. sep_index = args.index("--")
  519. parsed_args = parser.parse_args(args[:sep_index])
  520. parsed_args.paths = args[sep_index + 1 :]
  521. else:
  522. parsed_args = parser.parse_args(args)
  523. args = parsed_args
  524. # Determine if we should use color
  525. def _should_use_color():
  526. if args.color == "always":
  527. return True
  528. elif args.color == "never":
  529. return False
  530. else: # auto
  531. return sys.stdout.isatty()
  532. def _create_output_stream(outstream):
  533. """Create output stream, optionally with colorization."""
  534. if not _should_use_color():
  535. return outstream.buffer
  536. from .diff import ColorizedDiffStream
  537. if not ColorizedDiffStream.is_available():
  538. if args.color == "always":
  539. raise ImportError(
  540. "Rich is required for colored output. Install with: pip install 'dulwich[colordiff]'"
  541. )
  542. else:
  543. logging.warning(
  544. "Rich not available, disabling colored output. Install with: pip install 'dulwich[colordiff]'"
  545. )
  546. return outstream.buffer
  547. return ColorizedDiffStream(outstream.buffer)
  548. with Repo(".") as repo:
  549. config = repo.get_config_stack()
  550. with get_pager(config=config, cmd_name="diff") as outstream:
  551. output_stream = _create_output_stream(outstream)
  552. if len(args.committish) == 0:
  553. # Show diff for working tree or staged changes
  554. porcelain.diff(
  555. repo,
  556. staged=(args.staged or args.cached),
  557. paths=args.paths or None,
  558. outstream=output_stream,
  559. )
  560. elif len(args.committish) == 1:
  561. # Show diff between working tree and specified commit
  562. if args.staged or args.cached:
  563. parser.error("--staged/--cached cannot be used with commits")
  564. porcelain.diff(
  565. repo,
  566. commit=args.committish[0],
  567. staged=False,
  568. paths=args.paths or None,
  569. outstream=output_stream,
  570. )
  571. elif len(args.committish) == 2:
  572. # Show diff between two commits
  573. porcelain.diff(
  574. repo,
  575. commit=args.committish[0],
  576. commit2=args.committish[1],
  577. paths=args.paths or None,
  578. outstream=output_stream,
  579. )
  580. else:
  581. parser.error("Too many arguments - specify at most two commits")
  582. # Flush any remaining output
  583. if hasattr(output_stream, "flush"):
  584. output_stream.flush()
  585. class cmd_dump_pack(Command):
  586. def run(self, args) -> None:
  587. parser = argparse.ArgumentParser()
  588. parser.add_argument("filename", help="Pack file to dump")
  589. args = parser.parse_args(args)
  590. basename, _ = os.path.splitext(args.filename)
  591. x = Pack(basename)
  592. print(f"Object names checksum: {x.name()}")
  593. print(f"Checksum: {sha_to_hex(x.get_stored_checksum())!r}")
  594. x.check()
  595. print(f"Length: {len(x)}")
  596. for name in x:
  597. try:
  598. print(f"\t{x[name]}")
  599. except KeyError as k:
  600. print(f"\t{name}: Unable to resolve base {k}")
  601. except ApplyDeltaError as e:
  602. print(f"\t{name}: Unable to apply delta: {e!r}")
  603. class cmd_dump_index(Command):
  604. def run(self, args) -> None:
  605. parser = argparse.ArgumentParser()
  606. parser.add_argument("filename", help="Index file to dump")
  607. args = parser.parse_args(args)
  608. idx = Index(args.filename)
  609. for o in idx:
  610. print(o, idx[o])
  611. class cmd_init(Command):
  612. def run(self, args) -> None:
  613. parser = argparse.ArgumentParser()
  614. parser.add_argument(
  615. "--bare", action="store_true", help="Create a bare repository"
  616. )
  617. parser.add_argument(
  618. "path", nargs="?", default=os.getcwd(), help="Repository path"
  619. )
  620. args = parser.parse_args(args)
  621. porcelain.init(args.path, bare=args.bare)
  622. class cmd_clone(Command):
  623. def run(self, args) -> None:
  624. parser = argparse.ArgumentParser()
  625. parser.add_argument(
  626. "--bare",
  627. help="Whether to create a bare repository.",
  628. action="store_true",
  629. )
  630. parser.add_argument("--depth", type=int, help="Depth at which to fetch")
  631. parser.add_argument(
  632. "-b",
  633. "--branch",
  634. type=str,
  635. help="Check out branch instead of branch pointed to by remote HEAD",
  636. )
  637. parser.add_argument(
  638. "--refspec",
  639. type=str,
  640. help="References to fetch",
  641. action="append",
  642. )
  643. parser.add_argument(
  644. "--filter",
  645. dest="filter_spec",
  646. type=str,
  647. help="git-rev-list-style object filter",
  648. )
  649. parser.add_argument(
  650. "--protocol",
  651. type=int,
  652. help="Git protocol version to use",
  653. )
  654. parser.add_argument(
  655. "--recurse-submodules",
  656. action="store_true",
  657. help="Initialize and clone submodules",
  658. )
  659. parser.add_argument("source", help="Repository to clone from")
  660. parser.add_argument("target", nargs="?", help="Directory to clone into")
  661. args = parser.parse_args(args)
  662. try:
  663. porcelain.clone(
  664. args.source,
  665. args.target,
  666. bare=args.bare,
  667. depth=args.depth,
  668. branch=args.branch,
  669. refspec=args.refspec,
  670. filter_spec=args.filter_spec,
  671. protocol_version=args.protocol,
  672. recurse_submodules=args.recurse_submodules,
  673. )
  674. except GitProtocolError as e:
  675. print(f"{e}")
  676. def _get_commit_message_with_template(initial_message, repo=None, commit=None):
  677. """Get commit message with an initial message template."""
  678. # Start with the initial message
  679. template = initial_message
  680. if template and not template.endswith(b"\n"):
  681. template += b"\n"
  682. template += b"\n"
  683. template += b"# Please enter the commit message for your changes. Lines starting\n"
  684. template += b"# with '#' will be ignored, and an empty message aborts the commit.\n"
  685. template += b"#\n"
  686. # Add branch info if repo is provided
  687. if repo:
  688. try:
  689. ref_names, ref_sha = repo.refs.follow(b"HEAD")
  690. ref_path = ref_names[-1] # Get the final reference
  691. if ref_path.startswith(b"refs/heads/"):
  692. branch = ref_path[11:] # Remove 'refs/heads/' prefix
  693. else:
  694. branch = ref_path
  695. template += b"# On branch %s\n" % branch
  696. except (KeyError, IndexError):
  697. template += b"# On branch (unknown)\n"
  698. template += b"#\n"
  699. template += b"# Changes to be committed:\n"
  700. # Launch editor
  701. content = launch_editor(template)
  702. # Remove comment lines and strip
  703. lines = content.split(b"\n")
  704. message_lines = [line for line in lines if not line.strip().startswith(b"#")]
  705. message = b"\n".join(message_lines).strip()
  706. if not message:
  707. raise CommitMessageError("Aborting commit due to empty commit message")
  708. return message
  709. class cmd_commit(Command):
  710. def run(self, args) -> Optional[int]:
  711. parser = argparse.ArgumentParser()
  712. parser.add_argument("--message", "-m", help="Commit message")
  713. parser.add_argument(
  714. "-a",
  715. "--all",
  716. action="store_true",
  717. help="Automatically stage all tracked files that have been modified",
  718. )
  719. parser.add_argument(
  720. "--amend",
  721. action="store_true",
  722. help="Replace the tip of the current branch by creating a new commit",
  723. )
  724. args = parser.parse_args(args)
  725. message: Union[bytes, str, Callable]
  726. if args.message:
  727. message = args.message
  728. elif args.amend:
  729. # For amend, create a callable that opens editor with original message pre-populated
  730. def get_amend_message(repo, commit):
  731. # Get the original commit message from current HEAD
  732. try:
  733. head_commit = repo[repo.head()]
  734. original_message = head_commit.message
  735. except KeyError:
  736. original_message = b""
  737. # Open editor with original message
  738. return _get_commit_message_with_template(original_message, repo, commit)
  739. message = get_amend_message
  740. else:
  741. # For regular commits, use empty template
  742. def get_regular_message(repo, commit):
  743. return _get_commit_message_with_template(b"", repo, commit)
  744. message = get_regular_message
  745. try:
  746. porcelain.commit(".", message=message, all=args.all, amend=args.amend)
  747. except CommitMessageError as e:
  748. print(f"error: {e}", file=sys.stderr)
  749. return 1
  750. return None
  751. class cmd_commit_tree(Command):
  752. def run(self, args) -> None:
  753. parser = argparse.ArgumentParser()
  754. parser.add_argument("--message", "-m", required=True, help="Commit message")
  755. parser.add_argument("tree", help="Tree SHA to commit")
  756. args = parser.parse_args(args)
  757. porcelain.commit_tree(".", tree=args.tree, message=args.message)
  758. class cmd_update_server_info(Command):
  759. def run(self, args) -> None:
  760. porcelain.update_server_info(".")
  761. class cmd_symbolic_ref(Command):
  762. def run(self, args) -> None:
  763. parser = argparse.ArgumentParser()
  764. parser.add_argument("name", help="Symbolic reference name")
  765. parser.add_argument("ref", nargs="?", help="Target reference")
  766. parser.add_argument("--force", action="store_true", help="Force update")
  767. args = parser.parse_args(args)
  768. # If ref is provided, we're setting; otherwise we're reading
  769. if args.ref:
  770. # Set symbolic reference
  771. from .repo import Repo
  772. with Repo(".") as repo:
  773. repo.refs.set_symbolic_ref(args.name.encode(), args.ref.encode())
  774. else:
  775. # Read symbolic reference
  776. from .repo import Repo
  777. with Repo(".") as repo:
  778. try:
  779. target = repo.refs.read_ref(args.name.encode())
  780. if target.startswith(b"ref: "):
  781. print(target[5:].decode())
  782. else:
  783. print(target.decode())
  784. except KeyError:
  785. print(f"fatal: ref '{args.name}' is not a symbolic ref")
  786. class cmd_pack_refs(Command):
  787. def run(self, argv) -> None:
  788. parser = argparse.ArgumentParser()
  789. parser.add_argument("--all", action="store_true")
  790. # ignored, we never prune
  791. parser.add_argument("--no-prune", action="store_true")
  792. args = parser.parse_args(argv)
  793. porcelain.pack_refs(".", all=args.all)
  794. class cmd_show(Command):
  795. def run(self, argv) -> None:
  796. parser = argparse.ArgumentParser()
  797. parser.add_argument("objectish", type=str, nargs="*")
  798. args = parser.parse_args(argv)
  799. with Repo(".") as repo:
  800. config = repo.get_config_stack()
  801. with get_pager(config=config, cmd_name="show") as outstream:
  802. porcelain.show(repo, args.objectish or None, outstream=outstream)
  803. class cmd_diff_tree(Command):
  804. def run(self, args) -> None:
  805. parser = argparse.ArgumentParser()
  806. parser.add_argument("old_tree", help="Old tree SHA")
  807. parser.add_argument("new_tree", help="New tree SHA")
  808. args = parser.parse_args(args)
  809. porcelain.diff_tree(".", args.old_tree, args.new_tree)
  810. class cmd_rev_list(Command):
  811. def run(self, args) -> None:
  812. parser = argparse.ArgumentParser()
  813. parser.add_argument("commits", nargs="+", help="Commit IDs to list")
  814. args = parser.parse_args(args)
  815. porcelain.rev_list(".", args.commits)
  816. class cmd_tag(Command):
  817. def run(self, args) -> None:
  818. parser = argparse.ArgumentParser()
  819. parser.add_argument(
  820. "-a",
  821. "--annotated",
  822. help="Create an annotated tag.",
  823. action="store_true",
  824. )
  825. parser.add_argument(
  826. "-s", "--sign", help="Sign the annotated tag.", action="store_true"
  827. )
  828. parser.add_argument("tag_name", help="Name of the tag to create")
  829. args = parser.parse_args(args)
  830. porcelain.tag_create(
  831. ".", args.tag_name, annotated=args.annotated, sign=args.sign
  832. )
  833. class cmd_repack(Command):
  834. def run(self, args) -> None:
  835. parser = argparse.ArgumentParser()
  836. parser.parse_args(args)
  837. porcelain.repack(".")
  838. class cmd_reflog(Command):
  839. def run(self, args) -> None:
  840. parser = argparse.ArgumentParser()
  841. parser.add_argument(
  842. "ref", nargs="?", default="HEAD", help="Reference to show reflog for"
  843. )
  844. parser.add_argument(
  845. "--all", action="store_true", help="Show reflogs for all refs"
  846. )
  847. args = parser.parse_args(args)
  848. with Repo(".") as repo:
  849. config = repo.get_config_stack()
  850. with get_pager(config=config, cmd_name="reflog") as outstream:
  851. if args.all:
  852. # Show reflogs for all refs
  853. for ref_bytes, entry in porcelain.reflog(repo, all=True):
  854. ref_str = ref_bytes.decode("utf-8", "replace")
  855. short_new = entry.new_sha[:8].decode("ascii")
  856. outstream.write(
  857. f"{short_new} {ref_str}: {entry.message.decode('utf-8', 'replace')}\n"
  858. )
  859. else:
  860. ref = (
  861. args.ref.encode("utf-8")
  862. if isinstance(args.ref, str)
  863. else args.ref
  864. )
  865. for i, entry in enumerate(porcelain.reflog(repo, ref)):
  866. # Format similar to git reflog
  867. short_new = entry.new_sha[:8].decode("ascii")
  868. outstream.write(
  869. f"{short_new} {ref.decode('utf-8', 'replace')}@{{{i}}}: {entry.message.decode('utf-8', 'replace')}\n"
  870. )
  871. class cmd_reset(Command):
  872. def run(self, args) -> None:
  873. parser = argparse.ArgumentParser()
  874. mode_group = parser.add_mutually_exclusive_group()
  875. mode_group.add_argument(
  876. "--hard", action="store_true", help="Reset working tree and index"
  877. )
  878. mode_group.add_argument("--soft", action="store_true", help="Reset only HEAD")
  879. mode_group.add_argument(
  880. "--mixed", action="store_true", help="Reset HEAD and index"
  881. )
  882. parser.add_argument("treeish", nargs="?", help="Commit/tree to reset to")
  883. args = parser.parse_args(args)
  884. if args.hard:
  885. mode = "hard"
  886. elif args.soft:
  887. mode = "soft"
  888. elif args.mixed:
  889. mode = "mixed"
  890. else:
  891. # Default to mixed behavior
  892. mode = "mixed"
  893. # Use the porcelain.reset function for all modes
  894. porcelain.reset(".", mode=mode, treeish=args.treeish)
  895. class cmd_revert(Command):
  896. def run(self, args) -> None:
  897. parser = argparse.ArgumentParser()
  898. parser.add_argument(
  899. "--no-commit",
  900. "-n",
  901. action="store_true",
  902. help="Apply changes but don't create a commit",
  903. )
  904. parser.add_argument("-m", "--message", help="Custom commit message")
  905. parser.add_argument("commits", nargs="+", help="Commits to revert")
  906. args = parser.parse_args(args)
  907. result = porcelain.revert(
  908. ".", commits=args.commits, no_commit=args.no_commit, message=args.message
  909. )
  910. if result and not args.no_commit:
  911. print(f"[{result.decode('ascii')[:7]}] Revert completed")
  912. class cmd_daemon(Command):
  913. def run(self, args) -> None:
  914. from dulwich import log_utils
  915. from .protocol import TCP_GIT_PORT
  916. parser = argparse.ArgumentParser()
  917. parser.add_argument(
  918. "-l",
  919. "--listen_address",
  920. default="localhost",
  921. help="Binding IP address.",
  922. )
  923. parser.add_argument(
  924. "-p",
  925. "--port",
  926. type=int,
  927. default=TCP_GIT_PORT,
  928. help="Binding TCP port.",
  929. )
  930. parser.add_argument(
  931. "gitdir", nargs="?", default=".", help="Git directory to serve"
  932. )
  933. args = parser.parse_args(args)
  934. log_utils.default_logging_config()
  935. porcelain.daemon(args.gitdir, address=args.listen_address, port=args.port)
  936. class cmd_web_daemon(Command):
  937. def run(self, args) -> None:
  938. from dulwich import log_utils
  939. parser = argparse.ArgumentParser()
  940. parser.add_argument(
  941. "-l",
  942. "--listen_address",
  943. default="",
  944. help="Binding IP address.",
  945. )
  946. parser.add_argument(
  947. "-p",
  948. "--port",
  949. type=int,
  950. default=8000,
  951. help="Binding TCP port.",
  952. )
  953. parser.add_argument(
  954. "gitdir", nargs="?", default=".", help="Git directory to serve"
  955. )
  956. args = parser.parse_args(args)
  957. log_utils.default_logging_config()
  958. porcelain.web_daemon(args.gitdir, address=args.listen_address, port=args.port)
  959. class cmd_write_tree(Command):
  960. def run(self, args) -> None:
  961. parser = argparse.ArgumentParser()
  962. parser.parse_args(args)
  963. sys.stdout.write("{}\n".format(porcelain.write_tree(".").decode()))
  964. class cmd_receive_pack(Command):
  965. def run(self, args) -> None:
  966. parser = argparse.ArgumentParser()
  967. parser.add_argument("gitdir", nargs="?", default=".", help="Git directory")
  968. args = parser.parse_args(args)
  969. porcelain.receive_pack(args.gitdir)
  970. class cmd_upload_pack(Command):
  971. def run(self, args) -> None:
  972. parser = argparse.ArgumentParser()
  973. parser.add_argument("gitdir", nargs="?", default=".", help="Git directory")
  974. args = parser.parse_args(args)
  975. porcelain.upload_pack(args.gitdir)
  976. class cmd_status(Command):
  977. def run(self, args) -> None:
  978. parser = argparse.ArgumentParser()
  979. parser.add_argument("gitdir", nargs="?", default=".", help="Git directory")
  980. args = parser.parse_args(args)
  981. status = porcelain.status(args.gitdir)
  982. if any(names for (kind, names) in status.staged.items()):
  983. sys.stdout.write("Changes to be committed:\n\n")
  984. for kind, names in status.staged.items():
  985. for name in names:
  986. sys.stdout.write(
  987. f"\t{kind}: {name.decode(sys.getfilesystemencoding())}\n"
  988. )
  989. sys.stdout.write("\n")
  990. if status.unstaged:
  991. sys.stdout.write("Changes not staged for commit:\n\n")
  992. for name in status.unstaged:
  993. sys.stdout.write(f"\t{name.decode(sys.getfilesystemencoding())}\n")
  994. sys.stdout.write("\n")
  995. if status.untracked:
  996. sys.stdout.write("Untracked files:\n\n")
  997. for name in status.untracked:
  998. sys.stdout.write(f"\t{name}\n")
  999. sys.stdout.write("\n")
  1000. class cmd_ls_remote(Command):
  1001. def run(self, args) -> None:
  1002. parser = argparse.ArgumentParser()
  1003. parser.add_argument(
  1004. "--symref", action="store_true", help="Show symbolic references"
  1005. )
  1006. parser.add_argument("url", help="Remote URL to list references from")
  1007. args = parser.parse_args(args)
  1008. result = porcelain.ls_remote(args.url)
  1009. if args.symref:
  1010. # Show symrefs first, like git does
  1011. for ref, target in sorted(result.symrefs.items()):
  1012. sys.stdout.write(f"ref: {target.decode()}\t{ref.decode()}\n")
  1013. # Show regular refs
  1014. for ref in sorted(result.refs):
  1015. sys.stdout.write(f"{result.refs[ref].decode()}\t{ref.decode()}\n")
  1016. class cmd_ls_tree(Command):
  1017. def run(self, args) -> None:
  1018. parser = argparse.ArgumentParser()
  1019. parser.add_argument(
  1020. "-r",
  1021. "--recursive",
  1022. action="store_true",
  1023. help="Recursively list tree contents.",
  1024. )
  1025. parser.add_argument(
  1026. "--name-only", action="store_true", help="Only display name."
  1027. )
  1028. parser.add_argument("treeish", nargs="?", help="Tree-ish to list")
  1029. args = parser.parse_args(args)
  1030. with Repo(".") as repo:
  1031. config = repo.get_config_stack()
  1032. with get_pager(config=config, cmd_name="ls-tree") as outstream:
  1033. porcelain.ls_tree(
  1034. repo,
  1035. args.treeish,
  1036. outstream=outstream,
  1037. recursive=args.recursive,
  1038. name_only=args.name_only,
  1039. )
  1040. class cmd_pack_objects(Command):
  1041. def run(self, args) -> None:
  1042. parser = argparse.ArgumentParser()
  1043. parser.add_argument(
  1044. "--stdout", action="store_true", help="Write pack to stdout"
  1045. )
  1046. parser.add_argument("--deltify", action="store_true", help="Create deltas")
  1047. parser.add_argument(
  1048. "--no-reuse-deltas", action="store_true", help="Don't reuse existing deltas"
  1049. )
  1050. parser.add_argument("basename", nargs="?", help="Base name for pack files")
  1051. args = parser.parse_args(args)
  1052. if not args.stdout and not args.basename:
  1053. parser.error("basename required when not using --stdout")
  1054. object_ids = [line.strip() for line in sys.stdin.readlines()]
  1055. deltify = args.deltify
  1056. reuse_deltas = not args.no_reuse_deltas
  1057. if args.stdout:
  1058. packf = getattr(sys.stdout, "buffer", sys.stdout)
  1059. idxf = None
  1060. close = []
  1061. else:
  1062. packf = open(args.basename + ".pack", "wb")
  1063. idxf = open(args.basename + ".idx", "wb")
  1064. close = [packf, idxf]
  1065. porcelain.pack_objects(
  1066. ".", object_ids, packf, idxf, deltify=deltify, reuse_deltas=reuse_deltas
  1067. )
  1068. for f in close:
  1069. f.close()
  1070. class cmd_unpack_objects(Command):
  1071. def run(self, args) -> None:
  1072. parser = argparse.ArgumentParser()
  1073. parser.add_argument("pack_file", help="Pack file to unpack")
  1074. args = parser.parse_args(args)
  1075. count = porcelain.unpack_objects(args.pack_file)
  1076. print(f"Unpacked {count} objects")
  1077. class cmd_prune(Command):
  1078. def run(self, args) -> Optional[int]:
  1079. import datetime
  1080. import time
  1081. from dulwich.object_store import DEFAULT_TEMPFILE_GRACE_PERIOD
  1082. parser = argparse.ArgumentParser(
  1083. description="Remove temporary pack files left behind by interrupted operations"
  1084. )
  1085. parser.add_argument(
  1086. "--expire",
  1087. nargs="?",
  1088. const="2.weeks.ago",
  1089. help="Only prune files older than the specified date (default: 2.weeks.ago)",
  1090. )
  1091. parser.add_argument(
  1092. "--dry-run",
  1093. "-n",
  1094. action="store_true",
  1095. help="Only report what would be removed",
  1096. )
  1097. parser.add_argument(
  1098. "--verbose",
  1099. "-v",
  1100. action="store_true",
  1101. help="Report all actions",
  1102. )
  1103. args = parser.parse_args(args)
  1104. # Parse expire grace period
  1105. grace_period = DEFAULT_TEMPFILE_GRACE_PERIOD
  1106. if args.expire:
  1107. try:
  1108. grace_period = parse_relative_time(args.expire)
  1109. except ValueError:
  1110. # Try to parse as absolute date
  1111. try:
  1112. date = datetime.datetime.strptime(args.expire, "%Y-%m-%d")
  1113. grace_period = int(time.time() - date.timestamp())
  1114. except ValueError:
  1115. print(f"Error: Invalid expire date: {args.expire}", file=sys.stderr)
  1116. return 1
  1117. # Progress callback
  1118. def progress(msg):
  1119. if args.verbose:
  1120. print(msg)
  1121. try:
  1122. porcelain.prune(
  1123. ".",
  1124. grace_period=grace_period,
  1125. dry_run=args.dry_run,
  1126. progress=progress if args.verbose else None,
  1127. )
  1128. return None
  1129. except porcelain.Error as e:
  1130. print(f"Error: {e}", file=sys.stderr)
  1131. return 1
  1132. class cmd_pull(Command):
  1133. def run(self, args) -> None:
  1134. parser = argparse.ArgumentParser()
  1135. parser.add_argument("from_location", type=str)
  1136. parser.add_argument("refspec", type=str, nargs="*")
  1137. parser.add_argument("--filter", type=str, nargs=1)
  1138. parser.add_argument("--protocol", type=int)
  1139. args = parser.parse_args(args)
  1140. porcelain.pull(
  1141. ".",
  1142. args.from_location or None,
  1143. args.refspec or None,
  1144. filter_spec=args.filter,
  1145. protocol_version=args.protocol or None,
  1146. )
  1147. class cmd_push(Command):
  1148. def run(self, argv) -> Optional[int]:
  1149. parser = argparse.ArgumentParser()
  1150. parser.add_argument("-f", "--force", action="store_true", help="Force")
  1151. parser.add_argument("to_location", type=str)
  1152. parser.add_argument("refspec", type=str, nargs="*")
  1153. args = parser.parse_args(argv)
  1154. try:
  1155. porcelain.push(
  1156. ".", args.to_location, args.refspec or None, force=args.force
  1157. )
  1158. except porcelain.DivergedBranches:
  1159. sys.stderr.write("Diverged branches; specify --force to override")
  1160. return 1
  1161. return None
  1162. class cmd_remote_add(Command):
  1163. def run(self, args) -> None:
  1164. parser = argparse.ArgumentParser()
  1165. parser.add_argument("name", help="Name of the remote")
  1166. parser.add_argument("url", help="URL of the remote")
  1167. args = parser.parse_args(args)
  1168. porcelain.remote_add(".", args.name, args.url)
  1169. class SuperCommand(Command):
  1170. subcommands: ClassVar[dict[str, type[Command]]] = {}
  1171. default_command: ClassVar[Optional[type[Command]]] = None
  1172. def run(self, args):
  1173. if not args:
  1174. if self.default_command:
  1175. return self.default_command().run(args)
  1176. else:
  1177. print(
  1178. "Supported subcommands: {}".format(
  1179. ", ".join(self.subcommands.keys())
  1180. )
  1181. )
  1182. return False
  1183. cmd = args[0]
  1184. try:
  1185. cmd_kls = self.subcommands[cmd]
  1186. except KeyError:
  1187. print(f"No such subcommand: {args[0]}")
  1188. return False
  1189. return cmd_kls().run(args[1:])
  1190. class cmd_remote(SuperCommand):
  1191. subcommands: ClassVar[dict[str, type[Command]]] = {
  1192. "add": cmd_remote_add,
  1193. }
  1194. class cmd_submodule_list(Command):
  1195. def run(self, argv) -> None:
  1196. parser = argparse.ArgumentParser()
  1197. parser.parse_args(argv)
  1198. for path, sha in porcelain.submodule_list("."):
  1199. sys.stdout.write(f" {sha} {path}\n")
  1200. class cmd_submodule_init(Command):
  1201. def run(self, argv) -> None:
  1202. parser = argparse.ArgumentParser()
  1203. parser.parse_args(argv)
  1204. porcelain.submodule_init(".")
  1205. class cmd_submodule_add(Command):
  1206. def run(self, argv) -> None:
  1207. parser = argparse.ArgumentParser()
  1208. parser.add_argument("url", help="URL of repository to add as submodule")
  1209. parser.add_argument("path", nargs="?", help="Path where submodule should live")
  1210. parser.add_argument("--name", help="Name for the submodule")
  1211. args = parser.parse_args(argv)
  1212. porcelain.submodule_add(".", args.url, args.path, args.name)
  1213. class cmd_submodule_update(Command):
  1214. def run(self, argv) -> None:
  1215. parser = argparse.ArgumentParser()
  1216. parser.add_argument(
  1217. "--init", action="store_true", help="Initialize submodules first"
  1218. )
  1219. parser.add_argument(
  1220. "--force",
  1221. action="store_true",
  1222. help="Force update even if local changes exist",
  1223. )
  1224. parser.add_argument(
  1225. "paths", nargs="*", help="Specific submodule paths to update"
  1226. )
  1227. args = parser.parse_args(argv)
  1228. paths = args.paths if args.paths else None
  1229. porcelain.submodule_update(".", paths=paths, init=args.init, force=args.force)
  1230. class cmd_submodule(SuperCommand):
  1231. subcommands: ClassVar[dict[str, type[Command]]] = {
  1232. "add": cmd_submodule_add,
  1233. "init": cmd_submodule_init,
  1234. "list": cmd_submodule_list,
  1235. "update": cmd_submodule_update,
  1236. }
  1237. default_command = cmd_submodule_list
  1238. class cmd_check_ignore(Command):
  1239. def run(self, args):
  1240. parser = argparse.ArgumentParser()
  1241. parser.add_argument("paths", nargs="+", help="Paths to check")
  1242. args = parser.parse_args(args)
  1243. ret = 1
  1244. for path in porcelain.check_ignore(".", args.paths):
  1245. print(path)
  1246. ret = 0
  1247. return ret
  1248. class cmd_check_mailmap(Command):
  1249. def run(self, args) -> None:
  1250. parser = argparse.ArgumentParser()
  1251. parser.add_argument("identities", nargs="+", help="Identities to check")
  1252. args = parser.parse_args(args)
  1253. for identity in args.identities:
  1254. canonical_identity = porcelain.check_mailmap(".", identity)
  1255. print(canonical_identity)
  1256. class cmd_branch(Command):
  1257. def run(self, args) -> Optional[int]:
  1258. parser = argparse.ArgumentParser()
  1259. parser.add_argument(
  1260. "branch",
  1261. type=str,
  1262. help="Name of the branch",
  1263. )
  1264. parser.add_argument(
  1265. "-d",
  1266. "--delete",
  1267. action="store_true",
  1268. help="Delete branch",
  1269. )
  1270. args = parser.parse_args(args)
  1271. if not args.branch:
  1272. print("Usage: dulwich branch [-d] BRANCH_NAME")
  1273. return 1
  1274. if args.delete:
  1275. porcelain.branch_delete(".", name=args.branch)
  1276. else:
  1277. try:
  1278. porcelain.branch_create(".", name=args.branch)
  1279. except porcelain.Error as e:
  1280. sys.stderr.write(f"{e}")
  1281. return 1
  1282. return 0
  1283. class cmd_checkout(Command):
  1284. def run(self, args) -> Optional[int]:
  1285. parser = argparse.ArgumentParser()
  1286. parser.add_argument(
  1287. "target",
  1288. type=str,
  1289. help="Name of the branch, tag, or commit to checkout",
  1290. )
  1291. parser.add_argument(
  1292. "-f",
  1293. "--force",
  1294. action="store_true",
  1295. help="Force checkout",
  1296. )
  1297. parser.add_argument(
  1298. "-b",
  1299. "--new-branch",
  1300. type=str,
  1301. help="Create a new branch at the target and switch to it",
  1302. )
  1303. args = parser.parse_args(args)
  1304. if not args.target:
  1305. print("Usage: dulwich checkout TARGET [--force] [-b NEW_BRANCH]")
  1306. return 1
  1307. try:
  1308. porcelain.checkout(
  1309. ".", target=args.target, force=args.force, new_branch=args.new_branch
  1310. )
  1311. except porcelain.CheckoutError as e:
  1312. sys.stderr.write(f"{e}\n")
  1313. return 1
  1314. return 0
  1315. class cmd_stash_list(Command):
  1316. def run(self, args) -> None:
  1317. parser = argparse.ArgumentParser()
  1318. parser.parse_args(args)
  1319. for i, entry in porcelain.stash_list("."):
  1320. print("stash@{{{}}}: {}".format(i, entry.message.rstrip("\n")))
  1321. class cmd_stash_push(Command):
  1322. def run(self, args) -> None:
  1323. parser = argparse.ArgumentParser()
  1324. parser.parse_args(args)
  1325. porcelain.stash_push(".")
  1326. print("Saved working directory and index state")
  1327. class cmd_stash_pop(Command):
  1328. def run(self, args) -> None:
  1329. parser = argparse.ArgumentParser()
  1330. parser.parse_args(args)
  1331. porcelain.stash_pop(".")
  1332. print("Restored working directory and index state")
  1333. class cmd_bisect(SuperCommand):
  1334. """Git bisect command implementation."""
  1335. subcommands: ClassVar[dict[str, type[Command]]] = {}
  1336. def run(self, args):
  1337. parser = argparse.ArgumentParser(prog="dulwich bisect")
  1338. subparsers = parser.add_subparsers(dest="subcommand", help="bisect subcommands")
  1339. # bisect start
  1340. start_parser = subparsers.add_parser("start", help="Start a new bisect session")
  1341. start_parser.add_argument("bad", nargs="?", help="Bad commit")
  1342. start_parser.add_argument("good", nargs="*", help="Good commit(s)")
  1343. start_parser.add_argument(
  1344. "--no-checkout",
  1345. action="store_true",
  1346. help="Don't checkout commits during bisect",
  1347. )
  1348. start_parser.add_argument(
  1349. "--term-bad", default="bad", help="Term to use for bad commits"
  1350. )
  1351. start_parser.add_argument(
  1352. "--term-good", default="good", help="Term to use for good commits"
  1353. )
  1354. start_parser.add_argument(
  1355. "--", dest="paths", nargs="*", help="Paths to limit bisect to"
  1356. )
  1357. # bisect bad
  1358. bad_parser = subparsers.add_parser("bad", help="Mark a commit as bad")
  1359. bad_parser.add_argument("rev", nargs="?", help="Commit to mark as bad")
  1360. # bisect good
  1361. good_parser = subparsers.add_parser("good", help="Mark a commit as good")
  1362. good_parser.add_argument("rev", nargs="?", help="Commit to mark as good")
  1363. # bisect skip
  1364. skip_parser = subparsers.add_parser("skip", help="Skip commits")
  1365. skip_parser.add_argument("revs", nargs="*", help="Commits to skip")
  1366. # bisect reset
  1367. reset_parser = subparsers.add_parser("reset", help="Reset bisect state")
  1368. reset_parser.add_argument("commit", nargs="?", help="Commit to reset to")
  1369. # bisect log
  1370. subparsers.add_parser("log", help="Show bisect log")
  1371. # bisect replay
  1372. replay_parser = subparsers.add_parser("replay", help="Replay bisect log")
  1373. replay_parser.add_argument("logfile", help="Log file to replay")
  1374. # bisect help
  1375. subparsers.add_parser("help", help="Show help")
  1376. parsed_args = parser.parse_args(args)
  1377. if not parsed_args.subcommand:
  1378. parser.print_help()
  1379. return 1
  1380. try:
  1381. if parsed_args.subcommand == "start":
  1382. next_sha = porcelain.bisect_start(
  1383. bad=parsed_args.bad,
  1384. good=parsed_args.good if parsed_args.good else None,
  1385. paths=parsed_args.paths,
  1386. no_checkout=parsed_args.no_checkout,
  1387. term_bad=parsed_args.term_bad,
  1388. term_good=parsed_args.term_good,
  1389. )
  1390. if next_sha:
  1391. print(f"Bisecting: checking out '{next_sha.decode('ascii')}'")
  1392. elif parsed_args.subcommand == "bad":
  1393. next_sha = porcelain.bisect_bad(rev=parsed_args.rev)
  1394. if next_sha:
  1395. print(f"Bisecting: checking out '{next_sha.decode('ascii')}'")
  1396. else:
  1397. # Bisect complete - find the first bad commit
  1398. with porcelain.open_repo_closing(".") as r:
  1399. bad_ref = os.path.join(r.controldir(), "refs", "bisect", "bad")
  1400. with open(bad_ref, "rb") as f:
  1401. bad_sha = f.read().strip()
  1402. commit = r.object_store[bad_sha]
  1403. message = commit.message.decode(
  1404. "utf-8", errors="replace"
  1405. ).split("\n")[0]
  1406. print(f"{bad_sha.decode('ascii')} is the first bad commit")
  1407. print(f"commit {bad_sha.decode('ascii')}")
  1408. print(f" {message}")
  1409. elif parsed_args.subcommand == "good":
  1410. next_sha = porcelain.bisect_good(rev=parsed_args.rev)
  1411. if next_sha:
  1412. print(f"Bisecting: checking out '{next_sha.decode('ascii')}'")
  1413. elif parsed_args.subcommand == "skip":
  1414. next_sha = porcelain.bisect_skip(
  1415. revs=parsed_args.revs if parsed_args.revs else None
  1416. )
  1417. if next_sha:
  1418. print(f"Bisecting: checking out '{next_sha.decode('ascii')}'")
  1419. elif parsed_args.subcommand == "reset":
  1420. porcelain.bisect_reset(commit=parsed_args.commit)
  1421. print("Bisect reset")
  1422. elif parsed_args.subcommand == "log":
  1423. log = porcelain.bisect_log()
  1424. print(log, end="")
  1425. elif parsed_args.subcommand == "replay":
  1426. porcelain.bisect_replay(log_file=parsed_args.logfile)
  1427. print(f"Replayed bisect log from {parsed_args.logfile}")
  1428. elif parsed_args.subcommand == "help":
  1429. parser.print_help()
  1430. except porcelain.Error as e:
  1431. print(f"Error: {e}", file=sys.stderr)
  1432. return 1
  1433. except ValueError as e:
  1434. print(f"Error: {e}", file=sys.stderr)
  1435. return 1
  1436. return 0
  1437. class cmd_stash(SuperCommand):
  1438. subcommands: ClassVar[dict[str, type[Command]]] = {
  1439. "list": cmd_stash_list,
  1440. "pop": cmd_stash_pop,
  1441. "push": cmd_stash_push,
  1442. }
  1443. class cmd_ls_files(Command):
  1444. def run(self, args) -> None:
  1445. parser = argparse.ArgumentParser()
  1446. parser.parse_args(args)
  1447. for name in porcelain.ls_files("."):
  1448. print(name)
  1449. class cmd_describe(Command):
  1450. def run(self, args) -> None:
  1451. parser = argparse.ArgumentParser()
  1452. parser.parse_args(args)
  1453. print(porcelain.describe("."))
  1454. class cmd_merge(Command):
  1455. def run(self, args) -> Optional[int]:
  1456. parser = argparse.ArgumentParser()
  1457. parser.add_argument("commit", type=str, help="Commit to merge")
  1458. parser.add_argument(
  1459. "--no-commit", action="store_true", help="Do not create a merge commit"
  1460. )
  1461. parser.add_argument(
  1462. "--no-ff", action="store_true", help="Force create a merge commit"
  1463. )
  1464. parser.add_argument("-m", "--message", type=str, help="Merge commit message")
  1465. args = parser.parse_args(args)
  1466. try:
  1467. merge_commit_id, conflicts = porcelain.merge(
  1468. ".",
  1469. args.commit,
  1470. no_commit=args.no_commit,
  1471. no_ff=args.no_ff,
  1472. message=args.message,
  1473. )
  1474. if conflicts:
  1475. print(f"Merge conflicts in {len(conflicts)} file(s):")
  1476. for conflict_path in conflicts:
  1477. print(f" {conflict_path.decode()}")
  1478. print(
  1479. "\nAutomatic merge failed; fix conflicts and then commit the result."
  1480. )
  1481. return 1
  1482. elif merge_commit_id is None and not args.no_commit:
  1483. print("Already up to date.")
  1484. elif args.no_commit:
  1485. print("Automatic merge successful; not committing as requested.")
  1486. else:
  1487. print(
  1488. f"Merge successful. Created merge commit {merge_commit_id.decode()}"
  1489. )
  1490. return 0
  1491. except porcelain.Error as e:
  1492. print(f"Error: {e}")
  1493. return 1
  1494. class cmd_notes_add(Command):
  1495. def run(self, args) -> None:
  1496. parser = argparse.ArgumentParser()
  1497. parser.add_argument("object", help="Object to annotate")
  1498. parser.add_argument("-m", "--message", help="Note message", required=True)
  1499. parser.add_argument(
  1500. "--ref", default="commits", help="Notes ref (default: commits)"
  1501. )
  1502. args = parser.parse_args(args)
  1503. porcelain.notes_add(".", args.object, args.message, ref=args.ref)
  1504. class cmd_notes_show(Command):
  1505. def run(self, args) -> None:
  1506. parser = argparse.ArgumentParser()
  1507. parser.add_argument("object", help="Object to show notes for")
  1508. parser.add_argument(
  1509. "--ref", default="commits", help="Notes ref (default: commits)"
  1510. )
  1511. args = parser.parse_args(args)
  1512. note = porcelain.notes_show(".", args.object, ref=args.ref)
  1513. if note:
  1514. sys.stdout.buffer.write(note)
  1515. else:
  1516. print(f"No notes found for object {args.object}")
  1517. class cmd_notes_remove(Command):
  1518. def run(self, args) -> None:
  1519. parser = argparse.ArgumentParser()
  1520. parser.add_argument("object", help="Object to remove notes from")
  1521. parser.add_argument(
  1522. "--ref", default="commits", help="Notes ref (default: commits)"
  1523. )
  1524. args = parser.parse_args(args)
  1525. result = porcelain.notes_remove(".", args.object, ref=args.ref)
  1526. if result:
  1527. print(f"Removed notes for object {args.object}")
  1528. else:
  1529. print(f"No notes found for object {args.object}")
  1530. class cmd_notes_list(Command):
  1531. def run(self, args) -> None:
  1532. parser = argparse.ArgumentParser()
  1533. parser.add_argument(
  1534. "--ref", default="commits", help="Notes ref (default: commits)"
  1535. )
  1536. args = parser.parse_args(args)
  1537. notes = porcelain.notes_list(".", ref=args.ref)
  1538. for object_sha, note_content in notes:
  1539. print(f"{object_sha.hex()}")
  1540. class cmd_notes(SuperCommand):
  1541. subcommands: ClassVar[dict[str, type[Command]]] = {
  1542. "add": cmd_notes_add,
  1543. "show": cmd_notes_show,
  1544. "remove": cmd_notes_remove,
  1545. "list": cmd_notes_list,
  1546. }
  1547. default_command = cmd_notes_list
  1548. class cmd_cherry_pick(Command):
  1549. def run(self, args) -> Optional[int]:
  1550. parser = argparse.ArgumentParser(
  1551. description="Apply the changes introduced by some existing commits"
  1552. )
  1553. parser.add_argument("commit", nargs="?", help="Commit to cherry-pick")
  1554. parser.add_argument(
  1555. "-n",
  1556. "--no-commit",
  1557. action="store_true",
  1558. help="Apply changes without making a commit",
  1559. )
  1560. parser.add_argument(
  1561. "--continue",
  1562. dest="continue_",
  1563. action="store_true",
  1564. help="Continue after resolving conflicts",
  1565. )
  1566. parser.add_argument(
  1567. "--abort",
  1568. action="store_true",
  1569. help="Abort the current cherry-pick operation",
  1570. )
  1571. args = parser.parse_args(args)
  1572. # Check argument validity
  1573. if args.continue_ or args.abort:
  1574. if args.commit is not None:
  1575. parser.error("Cannot specify commit with --continue or --abort")
  1576. return 1
  1577. else:
  1578. if args.commit is None:
  1579. parser.error("Commit argument is required")
  1580. return 1
  1581. try:
  1582. commit_arg = args.commit
  1583. result = porcelain.cherry_pick(
  1584. ".",
  1585. commit_arg,
  1586. no_commit=args.no_commit,
  1587. continue_=args.continue_,
  1588. abort=args.abort,
  1589. )
  1590. if args.abort:
  1591. print("Cherry-pick aborted.")
  1592. elif args.continue_:
  1593. if result:
  1594. print(f"Cherry-pick completed: {result.decode()}")
  1595. else:
  1596. print("Cherry-pick completed.")
  1597. elif result is None:
  1598. if args.no_commit:
  1599. print("Cherry-pick applied successfully (no commit created).")
  1600. else:
  1601. # This shouldn't happen unless there were conflicts
  1602. print("Cherry-pick resulted in conflicts.")
  1603. else:
  1604. print(f"Cherry-pick successful: {result.decode()}")
  1605. return None
  1606. except porcelain.Error as e:
  1607. print(f"Error: {e}", file=sys.stderr)
  1608. return 1
  1609. class cmd_merge_tree(Command):
  1610. def run(self, args) -> Optional[int]:
  1611. parser = argparse.ArgumentParser(
  1612. description="Perform a tree-level merge without touching the working directory"
  1613. )
  1614. parser.add_argument(
  1615. "base_tree",
  1616. nargs="?",
  1617. help="The common ancestor tree (optional, defaults to empty tree)",
  1618. )
  1619. parser.add_argument("our_tree", help="Our side of the merge")
  1620. parser.add_argument("their_tree", help="Their side of the merge")
  1621. parser.add_argument(
  1622. "-z",
  1623. "--name-only",
  1624. action="store_true",
  1625. help="Output only conflict paths, null-terminated",
  1626. )
  1627. args = parser.parse_args(args)
  1628. try:
  1629. # Determine base tree - if only two args provided, base is None
  1630. if args.base_tree is None:
  1631. # Only two arguments provided
  1632. base_tree = None
  1633. our_tree = args.our_tree
  1634. their_tree = args.their_tree
  1635. else:
  1636. # Three arguments provided
  1637. base_tree = args.base_tree
  1638. our_tree = args.our_tree
  1639. their_tree = args.their_tree
  1640. merged_tree_id, conflicts = porcelain.merge_tree(
  1641. ".", base_tree, our_tree, their_tree
  1642. )
  1643. if args.name_only:
  1644. # Output only conflict paths, null-terminated
  1645. for conflict_path in conflicts:
  1646. sys.stdout.buffer.write(conflict_path)
  1647. sys.stdout.buffer.write(b"\0")
  1648. else:
  1649. # Output the merged tree SHA
  1650. print(merged_tree_id.decode("ascii"))
  1651. # Output conflict information
  1652. if conflicts:
  1653. print(f"\nConflicts in {len(conflicts)} file(s):")
  1654. for conflict_path in conflicts:
  1655. print(f" {conflict_path.decode()}")
  1656. return None
  1657. except porcelain.Error as e:
  1658. print(f"Error: {e}", file=sys.stderr)
  1659. return 1
  1660. except KeyError as e:
  1661. print(f"Error: Object not found: {e}", file=sys.stderr)
  1662. return 1
  1663. class cmd_gc(Command):
  1664. def run(self, args) -> Optional[int]:
  1665. import datetime
  1666. import time
  1667. parser = argparse.ArgumentParser()
  1668. parser.add_argument(
  1669. "--auto",
  1670. action="store_true",
  1671. help="Only run gc if needed",
  1672. )
  1673. parser.add_argument(
  1674. "--aggressive",
  1675. action="store_true",
  1676. help="Use more aggressive settings",
  1677. )
  1678. parser.add_argument(
  1679. "--no-prune",
  1680. action="store_true",
  1681. help="Do not prune unreachable objects",
  1682. )
  1683. parser.add_argument(
  1684. "--prune",
  1685. nargs="?",
  1686. const="now",
  1687. help="Prune unreachable objects older than date (default: 2 weeks ago)",
  1688. )
  1689. parser.add_argument(
  1690. "--dry-run",
  1691. "-n",
  1692. action="store_true",
  1693. help="Only report what would be done",
  1694. )
  1695. parser.add_argument(
  1696. "--quiet",
  1697. "-q",
  1698. action="store_true",
  1699. help="Only report errors",
  1700. )
  1701. args = parser.parse_args(args)
  1702. # Parse prune grace period
  1703. grace_period = None
  1704. if args.prune:
  1705. try:
  1706. grace_period = parse_relative_time(args.prune)
  1707. except ValueError:
  1708. # Try to parse as absolute date
  1709. try:
  1710. date = datetime.datetime.strptime(args.prune, "%Y-%m-%d")
  1711. grace_period = int(time.time() - date.timestamp())
  1712. except ValueError:
  1713. print(f"Error: Invalid prune date: {args.prune}")
  1714. return 1
  1715. elif not args.no_prune:
  1716. # Default to 2 weeks
  1717. grace_period = 1209600
  1718. # Progress callback
  1719. def progress(msg):
  1720. if not args.quiet:
  1721. print(msg)
  1722. try:
  1723. stats = porcelain.gc(
  1724. ".",
  1725. auto=args.auto,
  1726. aggressive=args.aggressive,
  1727. prune=not args.no_prune,
  1728. grace_period=grace_period,
  1729. dry_run=args.dry_run,
  1730. progress=progress if not args.quiet else None,
  1731. )
  1732. # Report results
  1733. if not args.quiet:
  1734. if args.dry_run:
  1735. print("\nDry run results:")
  1736. else:
  1737. print("\nGarbage collection complete:")
  1738. if stats.pruned_objects:
  1739. print(f" Pruned {len(stats.pruned_objects)} unreachable objects")
  1740. print(f" Freed {format_bytes(stats.bytes_freed)}")
  1741. if stats.packs_before != stats.packs_after:
  1742. print(
  1743. f" Reduced pack files from {stats.packs_before} to {stats.packs_after}"
  1744. )
  1745. except porcelain.Error as e:
  1746. print(f"Error: {e}")
  1747. return 1
  1748. return None
  1749. class cmd_count_objects(Command):
  1750. def run(self, args) -> None:
  1751. parser = argparse.ArgumentParser()
  1752. parser.add_argument(
  1753. "-v",
  1754. "--verbose",
  1755. action="store_true",
  1756. help="Display verbose information.",
  1757. )
  1758. args = parser.parse_args(args)
  1759. if args.verbose:
  1760. stats = porcelain.count_objects(".", verbose=True)
  1761. # Display verbose output
  1762. print(f"count: {stats.count}")
  1763. print(f"size: {stats.size // 1024}") # Size in KiB
  1764. assert stats.in_pack is not None
  1765. print(f"in-pack: {stats.in_pack}")
  1766. assert stats.packs is not None
  1767. print(f"packs: {stats.packs}")
  1768. assert stats.size_pack is not None
  1769. print(f"size-pack: {stats.size_pack // 1024}") # Size in KiB
  1770. else:
  1771. # Simple output
  1772. stats = porcelain.count_objects(".", verbose=False)
  1773. print(f"{stats.count} objects, {stats.size // 1024} kilobytes")
  1774. class cmd_rebase(Command):
  1775. def run(self, args) -> int:
  1776. parser = argparse.ArgumentParser()
  1777. parser.add_argument(
  1778. "upstream", nargs="?", help="Upstream branch to rebase onto"
  1779. )
  1780. parser.add_argument("--onto", type=str, help="Rebase onto specific commit")
  1781. parser.add_argument(
  1782. "--branch", type=str, help="Branch to rebase (default: current)"
  1783. )
  1784. parser.add_argument(
  1785. "--abort", action="store_true", help="Abort an in-progress rebase"
  1786. )
  1787. parser.add_argument(
  1788. "--continue",
  1789. dest="continue_rebase",
  1790. action="store_true",
  1791. help="Continue an in-progress rebase",
  1792. )
  1793. parser.add_argument(
  1794. "--skip", action="store_true", help="Skip current commit and continue"
  1795. )
  1796. args = parser.parse_args(args)
  1797. # Handle abort/continue/skip first
  1798. if args.abort:
  1799. try:
  1800. porcelain.rebase(".", args.upstream or "HEAD", abort=True)
  1801. print("Rebase aborted.")
  1802. except porcelain.Error as e:
  1803. print(f"Error: {e}")
  1804. return 1
  1805. return 0
  1806. if args.continue_rebase:
  1807. try:
  1808. new_shas = porcelain.rebase(
  1809. ".", args.upstream or "HEAD", continue_rebase=True
  1810. )
  1811. print("Rebase complete.")
  1812. except porcelain.Error as e:
  1813. print(f"Error: {e}")
  1814. return 1
  1815. return 0
  1816. # Normal rebase requires upstream
  1817. if not args.upstream:
  1818. print("Error: Missing required argument 'upstream'")
  1819. return 1
  1820. try:
  1821. new_shas = porcelain.rebase(
  1822. ".",
  1823. args.upstream,
  1824. onto=args.onto,
  1825. branch=args.branch,
  1826. )
  1827. if new_shas:
  1828. print(f"Successfully rebased {len(new_shas)} commits.")
  1829. else:
  1830. print("Already up to date.")
  1831. return 0
  1832. except porcelain.Error as e:
  1833. print(f"Error: {e}")
  1834. return 1
  1835. class cmd_filter_branch(Command):
  1836. def run(self, args) -> Optional[int]:
  1837. import subprocess
  1838. parser = argparse.ArgumentParser(description="Rewrite branches")
  1839. # Supported Git-compatible options
  1840. parser.add_argument(
  1841. "--subdirectory-filter",
  1842. type=str,
  1843. help="Only include history for subdirectory",
  1844. )
  1845. parser.add_argument("--env-filter", type=str, help="Environment filter command")
  1846. parser.add_argument("--tree-filter", type=str, help="Tree filter command")
  1847. parser.add_argument("--index-filter", type=str, help="Index filter command")
  1848. parser.add_argument("--parent-filter", type=str, help="Parent filter command")
  1849. parser.add_argument("--msg-filter", type=str, help="Message filter command")
  1850. parser.add_argument("--commit-filter", type=str, help="Commit filter command")
  1851. parser.add_argument(
  1852. "--tag-name-filter", type=str, help="Tag name filter command"
  1853. )
  1854. parser.add_argument(
  1855. "--prune-empty", action="store_true", help="Remove empty commits"
  1856. )
  1857. parser.add_argument(
  1858. "--original",
  1859. type=str,
  1860. default="refs/original",
  1861. help="Namespace for original refs",
  1862. )
  1863. parser.add_argument(
  1864. "-f",
  1865. "--force",
  1866. action="store_true",
  1867. help="Force operation even if refs/original/* exists",
  1868. )
  1869. # Branch/ref to rewrite (defaults to HEAD)
  1870. parser.add_argument(
  1871. "branch", nargs="?", default="HEAD", help="Branch or ref to rewrite"
  1872. )
  1873. args = parser.parse_args(args)
  1874. # Track if any filter fails
  1875. filter_error = False
  1876. # Setup environment for filters
  1877. env = os.environ.copy()
  1878. # Helper function to run shell commands
  1879. def run_filter(cmd, input_data=None, cwd=None, extra_env=None):
  1880. nonlocal filter_error
  1881. filter_env = env.copy()
  1882. if extra_env:
  1883. filter_env.update(extra_env)
  1884. result = subprocess.run(
  1885. cmd,
  1886. shell=True,
  1887. input=input_data,
  1888. cwd=cwd,
  1889. env=filter_env,
  1890. capture_output=True,
  1891. )
  1892. if result.returncode != 0:
  1893. filter_error = True
  1894. return None
  1895. return result.stdout
  1896. # Create filter functions based on arguments
  1897. filter_message = None
  1898. if args.msg_filter:
  1899. def filter_message(message):
  1900. result = run_filter(args.msg_filter, input_data=message)
  1901. return result if result is not None else message
  1902. tree_filter = None
  1903. if args.tree_filter:
  1904. def tree_filter(tree_sha, tmpdir):
  1905. from dulwich.objects import Blob, Tree
  1906. # Export tree to tmpdir
  1907. with Repo(".") as r:
  1908. tree = r.object_store[tree_sha]
  1909. for entry in tree.items():
  1910. path = Path(tmpdir) / entry.path.decode()
  1911. if entry.mode & 0o040000: # Directory
  1912. path.mkdir(exist_ok=True)
  1913. else:
  1914. obj = r.object_store[entry.sha]
  1915. path.write_bytes(obj.data)
  1916. # Run the filter command in the temp directory
  1917. run_filter(args.tree_filter, cwd=tmpdir)
  1918. # Rebuild tree from modified temp directory
  1919. def build_tree_from_dir(dir_path):
  1920. tree = Tree()
  1921. for name in sorted(os.listdir(dir_path)):
  1922. if name.startswith("."):
  1923. continue
  1924. path = os.path.join(dir_path, name)
  1925. if os.path.isdir(path):
  1926. subtree_sha = build_tree_from_dir(path)
  1927. tree.add(name.encode(), 0o040000, subtree_sha)
  1928. else:
  1929. with open(path, "rb") as f:
  1930. data = f.read()
  1931. blob = Blob.from_string(data)
  1932. r.object_store.add_object(blob)
  1933. # Use appropriate file mode
  1934. mode = os.stat(path).st_mode
  1935. if mode & 0o100:
  1936. file_mode = 0o100755
  1937. else:
  1938. file_mode = 0o100644
  1939. tree.add(name.encode(), file_mode, blob.id)
  1940. r.object_store.add_object(tree)
  1941. return tree.id
  1942. return build_tree_from_dir(tmpdir)
  1943. index_filter = None
  1944. if args.index_filter:
  1945. def index_filter(tree_sha, index_path):
  1946. run_filter(args.index_filter, extra_env={"GIT_INDEX_FILE": index_path})
  1947. return None # Read back from index
  1948. parent_filter = None
  1949. if args.parent_filter:
  1950. def parent_filter(parents):
  1951. parent_str = " ".join(p.hex() for p in parents)
  1952. result = run_filter(args.parent_filter, input_data=parent_str.encode())
  1953. if result is None:
  1954. return parents
  1955. output = result.decode().strip()
  1956. if not output:
  1957. return []
  1958. new_parents = []
  1959. for sha in output.split():
  1960. if valid_hexsha(sha):
  1961. new_parents.append(sha)
  1962. return new_parents
  1963. commit_filter = None
  1964. if args.commit_filter:
  1965. def commit_filter(commit_obj, tree_sha):
  1966. # The filter receives: tree parent1 parent2...
  1967. cmd_input = tree_sha.hex()
  1968. for parent in commit_obj.parents:
  1969. cmd_input += " " + parent.hex()
  1970. result = run_filter(
  1971. args.commit_filter,
  1972. input_data=cmd_input.encode(),
  1973. extra_env={"GIT_COMMIT": commit_obj.id.hex()},
  1974. )
  1975. if result is None:
  1976. return None
  1977. output = result.decode().strip()
  1978. if not output:
  1979. return None # Skip commit
  1980. if valid_hexsha(output):
  1981. return output
  1982. return None
  1983. tag_name_filter = None
  1984. if args.tag_name_filter:
  1985. def tag_name_filter(tag_name):
  1986. result = run_filter(args.tag_name_filter, input_data=tag_name)
  1987. return result.strip() if result is not None else tag_name
  1988. # Open repo once
  1989. with Repo(".") as r:
  1990. # Check for refs/original if not forcing
  1991. if not args.force:
  1992. original_prefix = args.original.encode() + b"/"
  1993. for ref in r.refs.allkeys():
  1994. if ref.startswith(original_prefix):
  1995. print("Cannot create a new backup.")
  1996. print(f"A previous backup already exists in {args.original}/")
  1997. print("Force overwriting the backup with -f")
  1998. return 1
  1999. try:
  2000. # Call porcelain.filter_branch with the repo object
  2001. result = porcelain.filter_branch(
  2002. r,
  2003. args.branch,
  2004. filter_message=filter_message,
  2005. tree_filter=tree_filter if args.tree_filter else None,
  2006. index_filter=index_filter if args.index_filter else None,
  2007. parent_filter=parent_filter if args.parent_filter else None,
  2008. commit_filter=commit_filter if args.commit_filter else None,
  2009. subdirectory_filter=args.subdirectory_filter,
  2010. prune_empty=args.prune_empty,
  2011. tag_name_filter=tag_name_filter if args.tag_name_filter else None,
  2012. force=args.force,
  2013. keep_original=True, # Always keep original with git
  2014. )
  2015. # Check if any filter failed
  2016. if filter_error:
  2017. print("Error: Filter command failed", file=sys.stderr)
  2018. return 1
  2019. # Git filter-branch shows progress
  2020. if result:
  2021. print(f"Rewrite {args.branch} ({len(result)} commits)")
  2022. # Git shows: Ref 'refs/heads/branch' was rewritten
  2023. if args.branch != "HEAD":
  2024. ref_name = (
  2025. args.branch
  2026. if args.branch.startswith("refs/")
  2027. else f"refs/heads/{args.branch}"
  2028. )
  2029. print(f"Ref '{ref_name}' was rewritten")
  2030. return 0
  2031. except porcelain.Error as e:
  2032. print(f"Error: {e}", file=sys.stderr)
  2033. return 1
  2034. class cmd_lfs(Command):
  2035. """Git LFS management commands."""
  2036. def run(self, argv) -> None:
  2037. parser = argparse.ArgumentParser(prog="dulwich lfs")
  2038. subparsers = parser.add_subparsers(dest="subcommand", help="LFS subcommands")
  2039. # lfs init
  2040. subparsers.add_parser("init", help="Initialize Git LFS")
  2041. # lfs track
  2042. parser_track = subparsers.add_parser(
  2043. "track", help="Track file patterns with LFS"
  2044. )
  2045. parser_track.add_argument("patterns", nargs="*", help="File patterns to track")
  2046. # lfs untrack
  2047. parser_untrack = subparsers.add_parser(
  2048. "untrack", help="Untrack file patterns from LFS"
  2049. )
  2050. parser_untrack.add_argument(
  2051. "patterns", nargs="+", help="File patterns to untrack"
  2052. )
  2053. # lfs ls-files
  2054. parser_ls = subparsers.add_parser("ls-files", help="List LFS files")
  2055. parser_ls.add_argument("--ref", help="Git ref to check (defaults to HEAD)")
  2056. # lfs migrate
  2057. parser_migrate = subparsers.add_parser("migrate", help="Migrate files to LFS")
  2058. parser_migrate.add_argument("--include", nargs="+", help="Patterns to include")
  2059. parser_migrate.add_argument("--exclude", nargs="+", help="Patterns to exclude")
  2060. parser_migrate.add_argument(
  2061. "--everything", action="store_true", help="Migrate all files above 100MB"
  2062. )
  2063. # lfs pointer
  2064. parser_pointer = subparsers.add_parser("pointer", help="Check LFS pointers")
  2065. parser_pointer.add_argument(
  2066. "--check", nargs="*", dest="paths", help="Check if files are LFS pointers"
  2067. )
  2068. # lfs clean
  2069. parser_clean = subparsers.add_parser("clean", help="Clean file to LFS pointer")
  2070. parser_clean.add_argument("path", help="File path to clean")
  2071. # lfs smudge
  2072. parser_smudge = subparsers.add_parser(
  2073. "smudge", help="Smudge LFS pointer to content"
  2074. )
  2075. parser_smudge.add_argument(
  2076. "--stdin", action="store_true", help="Read pointer from stdin"
  2077. )
  2078. # lfs fetch
  2079. parser_fetch = subparsers.add_parser(
  2080. "fetch", help="Fetch LFS objects from remote"
  2081. )
  2082. parser_fetch.add_argument(
  2083. "--remote", default="origin", help="Remote to fetch from"
  2084. )
  2085. parser_fetch.add_argument("refs", nargs="*", help="Specific refs to fetch")
  2086. # lfs pull
  2087. parser_pull = subparsers.add_parser(
  2088. "pull", help="Pull LFS objects for current checkout"
  2089. )
  2090. parser_pull.add_argument(
  2091. "--remote", default="origin", help="Remote to pull from"
  2092. )
  2093. # lfs push
  2094. parser_push = subparsers.add_parser("push", help="Push LFS objects to remote")
  2095. parser_push.add_argument("--remote", default="origin", help="Remote to push to")
  2096. parser_push.add_argument("refs", nargs="*", help="Specific refs to push")
  2097. # lfs status
  2098. subparsers.add_parser("status", help="Show status of LFS files")
  2099. args = parser.parse_args(argv)
  2100. if args.subcommand == "init":
  2101. porcelain.lfs_init()
  2102. print("Git LFS initialized.")
  2103. elif args.subcommand == "track":
  2104. if args.patterns:
  2105. tracked = porcelain.lfs_track(patterns=args.patterns)
  2106. print("Tracking patterns:")
  2107. else:
  2108. tracked = porcelain.lfs_track()
  2109. print("Currently tracked patterns:")
  2110. for pattern in tracked:
  2111. print(f" {pattern}")
  2112. elif args.subcommand == "untrack":
  2113. tracked = porcelain.lfs_untrack(patterns=args.patterns)
  2114. print("Remaining tracked patterns:")
  2115. for pattern in tracked:
  2116. print(f" {pattern}")
  2117. elif args.subcommand == "ls-files":
  2118. files = porcelain.lfs_ls_files(ref=args.ref)
  2119. for path, oid, size in files:
  2120. print(f"{oid[:12]} * {path} ({format_bytes(size)})")
  2121. elif args.subcommand == "migrate":
  2122. count = porcelain.lfs_migrate(
  2123. include=args.include, exclude=args.exclude, everything=args.everything
  2124. )
  2125. print(f"Migrated {count} file(s) to Git LFS.")
  2126. elif args.subcommand == "pointer":
  2127. if args.paths is not None:
  2128. results = porcelain.lfs_pointer_check(paths=args.paths or None)
  2129. for path, pointer in results.items():
  2130. if pointer:
  2131. print(
  2132. f"{path}: LFS pointer (oid: {pointer.oid[:12]}, size: {format_bytes(pointer.size)})"
  2133. )
  2134. else:
  2135. print(f"{path}: Not an LFS pointer")
  2136. elif args.subcommand == "clean":
  2137. pointer = porcelain.lfs_clean(path=args.path)
  2138. sys.stdout.buffer.write(pointer)
  2139. elif args.subcommand == "smudge":
  2140. if args.stdin:
  2141. pointer_content = sys.stdin.buffer.read()
  2142. content = porcelain.lfs_smudge(pointer_content=pointer_content)
  2143. sys.stdout.buffer.write(content)
  2144. else:
  2145. print("Error: --stdin required for smudge command")
  2146. sys.exit(1)
  2147. elif args.subcommand == "fetch":
  2148. refs = args.refs or None
  2149. count = porcelain.lfs_fetch(remote=args.remote, refs=refs)
  2150. print(f"Fetched {count} LFS object(s).")
  2151. elif args.subcommand == "pull":
  2152. count = porcelain.lfs_pull(remote=args.remote)
  2153. print(f"Pulled {count} LFS object(s).")
  2154. elif args.subcommand == "push":
  2155. refs = args.refs or None
  2156. count = porcelain.lfs_push(remote=args.remote, refs=refs)
  2157. print(f"Pushed {count} LFS object(s).")
  2158. elif args.subcommand == "status":
  2159. status = porcelain.lfs_status()
  2160. if status["tracked"]:
  2161. print(f"LFS tracked files: {len(status['tracked'])}")
  2162. if status["missing"]:
  2163. print("\nMissing LFS objects:")
  2164. for path in status["missing"]:
  2165. print(f" {path}")
  2166. if status["not_staged"]:
  2167. print("\nModified LFS files not staged:")
  2168. for path in status["not_staged"]:
  2169. print(f" {path}")
  2170. if not any(status.values()):
  2171. print("No LFS files found.")
  2172. else:
  2173. parser.print_help()
  2174. sys.exit(1)
  2175. class cmd_help(Command):
  2176. def run(self, args) -> None:
  2177. parser = argparse.ArgumentParser()
  2178. parser.add_argument(
  2179. "-a",
  2180. "--all",
  2181. action="store_true",
  2182. help="List all commands.",
  2183. )
  2184. args = parser.parse_args(args)
  2185. if args.all:
  2186. print("Available commands:")
  2187. for cmd in sorted(commands):
  2188. print(f" {cmd}")
  2189. else:
  2190. print(
  2191. """\
  2192. The dulwich command line tool is currently a very basic frontend for the
  2193. Dulwich python module. For full functionality, please see the API reference.
  2194. For a list of supported commands, see 'dulwich help -a'.
  2195. """
  2196. )
  2197. class cmd_format_patch(Command):
  2198. def run(self, args) -> None:
  2199. parser = argparse.ArgumentParser()
  2200. parser.add_argument(
  2201. "committish",
  2202. nargs="?",
  2203. help="Commit or commit range (e.g., HEAD~3..HEAD or origin/master..HEAD)",
  2204. )
  2205. parser.add_argument(
  2206. "-n",
  2207. "--numbered",
  2208. type=int,
  2209. default=1,
  2210. help="Number of commits to format (default: 1)",
  2211. )
  2212. parser.add_argument(
  2213. "-o",
  2214. "--output-directory",
  2215. dest="outdir",
  2216. help="Output directory for patches",
  2217. )
  2218. parser.add_argument(
  2219. "--stdout",
  2220. action="store_true",
  2221. help="Output patches to stdout",
  2222. )
  2223. args = parser.parse_args(args)
  2224. # Parse committish using the new function
  2225. committish = None
  2226. if args.committish:
  2227. with Repo(".") as r:
  2228. range_result = parse_commit_range(r, args.committish)
  2229. if range_result:
  2230. committish = range_result
  2231. else:
  2232. committish = args.committish
  2233. filenames = porcelain.format_patch(
  2234. ".",
  2235. committish=committish,
  2236. outstream=sys.stdout,
  2237. outdir=args.outdir,
  2238. n=args.numbered,
  2239. stdout=args.stdout,
  2240. )
  2241. if not args.stdout:
  2242. for filename in filenames:
  2243. print(filename)
  2244. class cmd_bundle(Command):
  2245. def run(self, args) -> int:
  2246. if not args:
  2247. print("Usage: bundle <create|verify|list-heads|unbundle> <options>")
  2248. return 1
  2249. subcommand = args[0]
  2250. subargs = args[1:]
  2251. if subcommand == "create":
  2252. return self._create(subargs)
  2253. elif subcommand == "verify":
  2254. return self._verify(subargs)
  2255. elif subcommand == "list-heads":
  2256. return self._list_heads(subargs)
  2257. elif subcommand == "unbundle":
  2258. return self._unbundle(subargs)
  2259. else:
  2260. print(f"Unknown bundle subcommand: {subcommand}")
  2261. return 1
  2262. def _create(self, args) -> int:
  2263. parser = argparse.ArgumentParser(prog="bundle create")
  2264. parser.add_argument(
  2265. "-q", "--quiet", action="store_true", help="Suppress progress"
  2266. )
  2267. parser.add_argument("--progress", action="store_true", help="Show progress")
  2268. parser.add_argument(
  2269. "--version", type=int, choices=[2, 3], help="Bundle version"
  2270. )
  2271. parser.add_argument("--all", action="store_true", help="Include all refs")
  2272. parser.add_argument("--stdin", action="store_true", help="Read refs from stdin")
  2273. parser.add_argument("file", help="Output bundle file (use - for stdout)")
  2274. parser.add_argument("refs", nargs="*", help="References or rev-list args")
  2275. parsed_args = parser.parse_args(args)
  2276. repo = Repo(".")
  2277. progress = None
  2278. if parsed_args.progress and not parsed_args.quiet:
  2279. def progress(msg: str) -> None:
  2280. print(msg, file=sys.stderr)
  2281. refs_to_include = []
  2282. prerequisites = []
  2283. if parsed_args.all:
  2284. refs_to_include = list(repo.refs.keys())
  2285. elif parsed_args.stdin:
  2286. for line in sys.stdin:
  2287. ref = line.strip().encode("utf-8")
  2288. if ref:
  2289. refs_to_include.append(ref)
  2290. elif parsed_args.refs:
  2291. for ref_arg in parsed_args.refs:
  2292. if ".." in ref_arg:
  2293. range_result = parse_commit_range(repo, ref_arg)
  2294. if range_result:
  2295. start_commit, end_commit = range_result
  2296. prerequisites.append(start_commit.id)
  2297. # For ranges like A..B, we need to include B if it's a ref
  2298. # Split the range to get the end part
  2299. end_part = ref_arg.split("..")[1]
  2300. if end_part: # Not empty (not "A..")
  2301. end_ref = end_part.encode("utf-8")
  2302. if end_ref in repo.refs:
  2303. refs_to_include.append(end_ref)
  2304. else:
  2305. sha = repo.refs[ref_arg.encode("utf-8")]
  2306. refs_to_include.append(ref_arg.encode("utf-8"))
  2307. else:
  2308. if ref_arg.startswith("^"):
  2309. sha = repo.refs[ref_arg[1:].encode("utf-8")]
  2310. prerequisites.append(sha)
  2311. else:
  2312. sha = repo.refs[ref_arg.encode("utf-8")]
  2313. refs_to_include.append(ref_arg.encode("utf-8"))
  2314. else:
  2315. print("No refs specified. Use --all, --stdin, or specify refs")
  2316. return 1
  2317. if not refs_to_include:
  2318. print("fatal: Refusing to create empty bundle.")
  2319. return 1
  2320. bundle = create_bundle_from_repo(
  2321. repo,
  2322. refs=refs_to_include,
  2323. prerequisites=prerequisites,
  2324. version=parsed_args.version,
  2325. progress=progress,
  2326. )
  2327. if parsed_args.file == "-":
  2328. write_bundle(sys.stdout.buffer, bundle)
  2329. else:
  2330. with open(parsed_args.file, "wb") as f:
  2331. write_bundle(f, bundle)
  2332. return 0
  2333. def _verify(self, args) -> int:
  2334. parser = argparse.ArgumentParser(prog="bundle verify")
  2335. parser.add_argument(
  2336. "-q", "--quiet", action="store_true", help="Suppress output"
  2337. )
  2338. parser.add_argument("file", help="Bundle file to verify (use - for stdin)")
  2339. parsed_args = parser.parse_args(args)
  2340. repo = Repo(".")
  2341. def verify_bundle(bundle):
  2342. missing_prereqs = []
  2343. for prereq_sha, comment in bundle.prerequisites:
  2344. try:
  2345. repo.object_store[prereq_sha]
  2346. except KeyError:
  2347. missing_prereqs.append(prereq_sha)
  2348. if missing_prereqs:
  2349. if not parsed_args.quiet:
  2350. print("The bundle requires these prerequisite commits:")
  2351. for sha in missing_prereqs:
  2352. print(f" {sha.decode()}")
  2353. return 1
  2354. else:
  2355. if not parsed_args.quiet:
  2356. print(
  2357. "The bundle is valid and can be applied to the current repository"
  2358. )
  2359. return 0
  2360. if parsed_args.file == "-":
  2361. bundle = read_bundle(sys.stdin.buffer)
  2362. return verify_bundle(bundle)
  2363. else:
  2364. with open(parsed_args.file, "rb") as f:
  2365. bundle = read_bundle(f)
  2366. return verify_bundle(bundle)
  2367. def _list_heads(self, args) -> int:
  2368. parser = argparse.ArgumentParser(prog="bundle list-heads")
  2369. parser.add_argument("file", help="Bundle file (use - for stdin)")
  2370. parser.add_argument("refnames", nargs="*", help="Only show these refs")
  2371. parsed_args = parser.parse_args(args)
  2372. def list_heads(bundle):
  2373. for ref, sha in bundle.references.items():
  2374. if not parsed_args.refnames or ref.decode() in parsed_args.refnames:
  2375. print(f"{sha.decode()} {ref.decode()}")
  2376. if parsed_args.file == "-":
  2377. bundle = read_bundle(sys.stdin.buffer)
  2378. list_heads(bundle)
  2379. else:
  2380. with open(parsed_args.file, "rb") as f:
  2381. bundle = read_bundle(f)
  2382. list_heads(bundle)
  2383. return 0
  2384. def _unbundle(self, args) -> int:
  2385. parser = argparse.ArgumentParser(prog="bundle unbundle")
  2386. parser.add_argument("--progress", action="store_true", help="Show progress")
  2387. parser.add_argument("file", help="Bundle file (use - for stdin)")
  2388. parser.add_argument("refnames", nargs="*", help="Only unbundle these refs")
  2389. parsed_args = parser.parse_args(args)
  2390. repo = Repo(".")
  2391. progress = None
  2392. if parsed_args.progress:
  2393. def progress(msg: str) -> None:
  2394. print(msg, file=sys.stderr)
  2395. if parsed_args.file == "-":
  2396. bundle = read_bundle(sys.stdin.buffer)
  2397. # Process the bundle while file is still available via stdin
  2398. bundle.store_objects(repo.object_store, progress=progress)
  2399. else:
  2400. # Keep the file open during bundle processing
  2401. with open(parsed_args.file, "rb") as f:
  2402. bundle = read_bundle(f)
  2403. # Process pack data while file is still open
  2404. bundle.store_objects(repo.object_store, progress=progress)
  2405. for ref, sha in bundle.references.items():
  2406. if not parsed_args.refnames or ref.decode() in parsed_args.refnames:
  2407. print(ref.decode())
  2408. return 0
  2409. commands = {
  2410. "add": cmd_add,
  2411. "annotate": cmd_annotate,
  2412. "archive": cmd_archive,
  2413. "bisect": cmd_bisect,
  2414. "blame": cmd_blame,
  2415. "branch": cmd_branch,
  2416. "bundle": cmd_bundle,
  2417. "check-ignore": cmd_check_ignore,
  2418. "check-mailmap": cmd_check_mailmap,
  2419. "checkout": cmd_checkout,
  2420. "cherry-pick": cmd_cherry_pick,
  2421. "clone": cmd_clone,
  2422. "commit": cmd_commit,
  2423. "commit-tree": cmd_commit_tree,
  2424. "count-objects": cmd_count_objects,
  2425. "describe": cmd_describe,
  2426. "daemon": cmd_daemon,
  2427. "diff": cmd_diff,
  2428. "diff-tree": cmd_diff_tree,
  2429. "dump-pack": cmd_dump_pack,
  2430. "dump-index": cmd_dump_index,
  2431. "fetch-pack": cmd_fetch_pack,
  2432. "fetch": cmd_fetch,
  2433. "filter-branch": cmd_filter_branch,
  2434. "for-each-ref": cmd_for_each_ref,
  2435. "format-patch": cmd_format_patch,
  2436. "fsck": cmd_fsck,
  2437. "gc": cmd_gc,
  2438. "help": cmd_help,
  2439. "init": cmd_init,
  2440. "lfs": cmd_lfs,
  2441. "log": cmd_log,
  2442. "ls-files": cmd_ls_files,
  2443. "ls-remote": cmd_ls_remote,
  2444. "ls-tree": cmd_ls_tree,
  2445. "merge": cmd_merge,
  2446. "merge-tree": cmd_merge_tree,
  2447. "notes": cmd_notes,
  2448. "pack-objects": cmd_pack_objects,
  2449. "pack-refs": cmd_pack_refs,
  2450. "prune": cmd_prune,
  2451. "pull": cmd_pull,
  2452. "push": cmd_push,
  2453. "rebase": cmd_rebase,
  2454. "receive-pack": cmd_receive_pack,
  2455. "reflog": cmd_reflog,
  2456. "remote": cmd_remote,
  2457. "repack": cmd_repack,
  2458. "reset": cmd_reset,
  2459. "revert": cmd_revert,
  2460. "rev-list": cmd_rev_list,
  2461. "rm": cmd_rm,
  2462. "mv": cmd_mv,
  2463. "show": cmd_show,
  2464. "stash": cmd_stash,
  2465. "status": cmd_status,
  2466. "symbolic-ref": cmd_symbolic_ref,
  2467. "submodule": cmd_submodule,
  2468. "tag": cmd_tag,
  2469. "unpack-objects": cmd_unpack_objects,
  2470. "update-server-info": cmd_update_server_info,
  2471. "upload-pack": cmd_upload_pack,
  2472. "web-daemon": cmd_web_daemon,
  2473. "write-tree": cmd_write_tree,
  2474. }
  2475. def main(argv=None) -> Optional[int]:
  2476. if argv is None:
  2477. argv = sys.argv[1:]
  2478. # Parse only the global options and command, stop at first positional
  2479. parser = argparse.ArgumentParser(
  2480. prog="dulwich",
  2481. description="Simple command-line interface to Dulwich",
  2482. add_help=False, # We'll handle help ourselves
  2483. )
  2484. parser.add_argument("--no-pager", action="store_true", help="Disable pager")
  2485. parser.add_argument("--pager", action="store_true", help="Force enable pager")
  2486. parser.add_argument("--help", "-h", action="store_true", help="Show help")
  2487. # Parse known args to separate global options from command args
  2488. global_args, remaining = parser.parse_known_args(argv)
  2489. # Apply global pager settings
  2490. if global_args.no_pager:
  2491. disable_pager()
  2492. elif global_args.pager:
  2493. enable_pager()
  2494. # Handle help
  2495. if global_args.help or not remaining:
  2496. parser = argparse.ArgumentParser(
  2497. prog="dulwich", description="Simple command-line interface to Dulwich"
  2498. )
  2499. parser.add_argument("--no-pager", action="store_true", help="Disable pager")
  2500. parser.add_argument("--pager", action="store_true", help="Force enable pager")
  2501. parser.add_argument(
  2502. "command",
  2503. nargs="?",
  2504. help=f"Command to run. Available: {', '.join(sorted(commands.keys()))}",
  2505. )
  2506. parser.print_help()
  2507. return 1
  2508. # First remaining arg is the command
  2509. cmd = remaining[0]
  2510. cmd_args = remaining[1:]
  2511. try:
  2512. cmd_kls = commands[cmd]
  2513. except KeyError:
  2514. print(f"No such subcommand: {cmd}")
  2515. return 1
  2516. # TODO(jelmer): Return non-0 on errors
  2517. return cmd_kls().run(cmd_args)
  2518. def _main() -> None:
  2519. if "DULWICH_PDB" in os.environ and getattr(signal, "SIGQUIT", None):
  2520. signal.signal(signal.SIGQUIT, signal_quit) # type: ignore
  2521. signal.signal(signal.SIGINT, signal_int)
  2522. sys.exit(main())
  2523. if __name__ == "__main__":
  2524. _main()