cli.py 61 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890
  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 public 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 os
  30. import signal
  31. import sys
  32. from pathlib import Path
  33. from typing import ClassVar, Optional
  34. from dulwich import porcelain
  35. from .client import GitProtocolError, get_transport_and_path
  36. from .errors import ApplyDeltaError
  37. from .index import Index
  38. from .objects import valid_hexsha
  39. from .objectspec import parse_commit
  40. from .pack import Pack, sha_to_hex
  41. from .repo import Repo
  42. def signal_int(signal, frame) -> None:
  43. sys.exit(1)
  44. def signal_quit(signal, frame) -> None:
  45. import pdb
  46. pdb.set_trace()
  47. def parse_relative_time(time_str):
  48. """Parse a relative time string like '2 weeks ago' into seconds.
  49. Args:
  50. time_str: String like '2 weeks ago' or 'now'
  51. Returns:
  52. Number of seconds
  53. Raises:
  54. ValueError: If the time string cannot be parsed
  55. """
  56. if time_str == "now":
  57. return 0
  58. if not time_str.endswith(" ago"):
  59. raise ValueError(f"Invalid relative time format: {time_str}")
  60. parts = time_str[:-4].split()
  61. if len(parts) != 2:
  62. raise ValueError(f"Invalid relative time format: {time_str}")
  63. try:
  64. num = int(parts[0])
  65. unit = parts[1]
  66. multipliers = {
  67. "second": 1,
  68. "seconds": 1,
  69. "minute": 60,
  70. "minutes": 60,
  71. "hour": 3600,
  72. "hours": 3600,
  73. "day": 86400,
  74. "days": 86400,
  75. "week": 604800,
  76. "weeks": 604800,
  77. }
  78. if unit in multipliers:
  79. return num * multipliers[unit]
  80. else:
  81. raise ValueError(f"Unknown time unit: {unit}")
  82. except ValueError as e:
  83. if "invalid literal" in str(e):
  84. raise ValueError(f"Invalid number in relative time: {parts[0]}")
  85. raise
  86. def format_bytes(bytes):
  87. """Format bytes as human-readable string.
  88. Args:
  89. bytes: Number of bytes
  90. Returns:
  91. Human-readable string like "1.5 MB"
  92. """
  93. for unit in ["B", "KB", "MB", "GB"]:
  94. if bytes < 1024.0:
  95. return f"{bytes:.1f} {unit}"
  96. bytes /= 1024.0
  97. return f"{bytes:.1f} TB"
  98. class Command:
  99. """A Dulwich subcommand."""
  100. def run(self, args) -> Optional[int]:
  101. """Run the command."""
  102. raise NotImplementedError(self.run)
  103. class cmd_archive(Command):
  104. def run(self, args) -> None:
  105. parser = argparse.ArgumentParser()
  106. parser.add_argument(
  107. "--remote",
  108. type=str,
  109. help="Retrieve archive from specified remote repo",
  110. )
  111. parser.add_argument("committish", type=str, nargs="?")
  112. args = parser.parse_args(args)
  113. if args.remote:
  114. client, path = get_transport_and_path(args.remote)
  115. client.archive(
  116. path,
  117. args.committish,
  118. sys.stdout.write,
  119. write_error=sys.stderr.write,
  120. )
  121. else:
  122. # Use buffer if available (for binary output), otherwise use stdout
  123. outstream = getattr(sys.stdout, "buffer", sys.stdout)
  124. porcelain.archive(
  125. ".", args.committish, outstream=outstream, errstream=sys.stderr
  126. )
  127. class cmd_add(Command):
  128. def run(self, argv) -> None:
  129. parser = argparse.ArgumentParser()
  130. parser.add_argument("path", nargs="+")
  131. args = parser.parse_args(argv)
  132. # Convert '.' to None to add all files
  133. paths = args.path
  134. if len(paths) == 1 and paths[0] == ".":
  135. paths = None
  136. porcelain.add(".", paths=paths)
  137. class cmd_annotate(Command):
  138. def run(self, argv) -> None:
  139. parser = argparse.ArgumentParser()
  140. parser.add_argument("path", help="Path to file to annotate")
  141. parser.add_argument("committish", nargs="?", help="Commit to start from")
  142. args = parser.parse_args(argv)
  143. results = porcelain.annotate(".", args.path, args.committish)
  144. for (commit, entry), line in results:
  145. # Show shortened commit hash and line content
  146. commit_hash = commit.id[:8]
  147. print(f"{commit_hash.decode()} {line.decode()}")
  148. class cmd_blame(Command):
  149. def run(self, argv) -> None:
  150. # blame is an alias for annotate
  151. cmd_annotate().run(argv)
  152. class cmd_rm(Command):
  153. def run(self, argv) -> None:
  154. parser = argparse.ArgumentParser()
  155. parser.add_argument(
  156. "--cached", action="store_true", help="Remove from index only"
  157. )
  158. parser.add_argument("path", type=Path, nargs="+")
  159. args = parser.parse_args(argv)
  160. porcelain.remove(".", paths=args.path, cached=args.cached)
  161. class cmd_mv(Command):
  162. def run(self, argv) -> None:
  163. parser = argparse.ArgumentParser()
  164. parser.add_argument(
  165. "-f",
  166. "--force",
  167. action="store_true",
  168. help="Force move even if destination exists",
  169. )
  170. parser.add_argument("source", type=Path)
  171. parser.add_argument("destination", type=Path)
  172. args = parser.parse_args(argv)
  173. porcelain.mv(".", args.source, args.destination, force=args.force)
  174. class cmd_fetch_pack(Command):
  175. def run(self, argv) -> None:
  176. parser = argparse.ArgumentParser()
  177. parser.add_argument("--all", action="store_true")
  178. parser.add_argument("location", nargs="?", type=str)
  179. parser.add_argument("refs", nargs="*", type=str)
  180. args = parser.parse_args(argv)
  181. client, path = get_transport_and_path(args.location)
  182. r = Repo(".")
  183. if args.all:
  184. determine_wants = r.object_store.determine_wants_all
  185. else:
  186. def determine_wants(refs, depth: Optional[int] = None):
  187. return [y.encode("utf-8") for y in args.refs if y not in r.object_store]
  188. client.fetch(path, r, determine_wants)
  189. class cmd_fetch(Command):
  190. def run(self, args) -> None:
  191. parser = argparse.ArgumentParser()
  192. parser.add_argument("location", help="Remote location to fetch from")
  193. args = parser.parse_args(args)
  194. client, path = get_transport_and_path(args.location)
  195. r = Repo(".")
  196. def progress(msg: bytes) -> None:
  197. sys.stdout.buffer.write(msg)
  198. refs = client.fetch(path, r, progress=progress)
  199. print("Remote refs:")
  200. for item in refs.items():
  201. print("{} -> {}".format(*item))
  202. class cmd_for_each_ref(Command):
  203. def run(self, args) -> None:
  204. parser = argparse.ArgumentParser()
  205. parser.add_argument("pattern", type=str, nargs="?")
  206. args = parser.parse_args(args)
  207. for sha, object_type, ref in porcelain.for_each_ref(".", args.pattern):
  208. print(f"{sha.decode()} {object_type.decode()}\t{ref.decode()}")
  209. class cmd_fsck(Command):
  210. def run(self, args) -> None:
  211. parser = argparse.ArgumentParser()
  212. parser.parse_args(args)
  213. for obj, msg in porcelain.fsck("."):
  214. print(f"{obj}: {msg}")
  215. class cmd_log(Command):
  216. def run(self, args) -> None:
  217. parser = argparse.ArgumentParser()
  218. parser.add_argument(
  219. "--reverse",
  220. action="store_true",
  221. help="Reverse order in which entries are printed",
  222. )
  223. parser.add_argument(
  224. "--name-status",
  225. action="store_true",
  226. help="Print name/status for each changed file",
  227. )
  228. parser.add_argument("paths", nargs="*", help="Paths to show log for")
  229. args = parser.parse_args(args)
  230. porcelain.log(
  231. ".",
  232. paths=args.paths,
  233. reverse=args.reverse,
  234. name_status=args.name_status,
  235. outstream=sys.stdout,
  236. )
  237. class cmd_diff(Command):
  238. def run(self, args) -> None:
  239. parser = argparse.ArgumentParser()
  240. parser.add_argument(
  241. "commit", nargs="?", default="HEAD", help="Commit to show diff for"
  242. )
  243. args = parser.parse_args(args)
  244. r = Repo(".")
  245. commit_id = (
  246. args.commit.encode() if isinstance(args.commit, str) else args.commit
  247. )
  248. commit = parse_commit(r, commit_id)
  249. parent_commit = r[commit.parents[0]]
  250. porcelain.diff_tree(
  251. r, parent_commit.tree, commit.tree, outstream=sys.stdout.buffer
  252. )
  253. class cmd_dump_pack(Command):
  254. def run(self, args) -> None:
  255. parser = argparse.ArgumentParser()
  256. parser.add_argument("filename", help="Pack file to dump")
  257. args = parser.parse_args(args)
  258. basename, _ = os.path.splitext(args.filename)
  259. x = Pack(basename)
  260. print(f"Object names checksum: {x.name()}")
  261. print(f"Checksum: {sha_to_hex(x.get_stored_checksum())!r}")
  262. x.check()
  263. print(f"Length: {len(x)}")
  264. for name in x:
  265. try:
  266. print(f"\t{x[name]}")
  267. except KeyError as k:
  268. print(f"\t{name}: Unable to resolve base {k}")
  269. except ApplyDeltaError as e:
  270. print(f"\t{name}: Unable to apply delta: {e!r}")
  271. class cmd_dump_index(Command):
  272. def run(self, args) -> None:
  273. parser = argparse.ArgumentParser()
  274. parser.add_argument("filename", help="Index file to dump")
  275. args = parser.parse_args(args)
  276. idx = Index(args.filename)
  277. for o in idx:
  278. print(o, idx[o])
  279. class cmd_init(Command):
  280. def run(self, args) -> None:
  281. parser = argparse.ArgumentParser()
  282. parser.add_argument(
  283. "--bare", action="store_true", help="Create a bare repository"
  284. )
  285. parser.add_argument(
  286. "path", nargs="?", default=os.getcwd(), help="Repository path"
  287. )
  288. args = parser.parse_args(args)
  289. porcelain.init(args.path, bare=args.bare)
  290. class cmd_clone(Command):
  291. def run(self, args) -> None:
  292. parser = argparse.ArgumentParser()
  293. parser.add_argument(
  294. "--bare",
  295. help="Whether to create a bare repository.",
  296. action="store_true",
  297. )
  298. parser.add_argument("--depth", type=int, help="Depth at which to fetch")
  299. parser.add_argument(
  300. "-b",
  301. "--branch",
  302. type=str,
  303. help="Check out branch instead of branch pointed to by remote HEAD",
  304. )
  305. parser.add_argument(
  306. "--refspec",
  307. type=str,
  308. help="References to fetch",
  309. action="append",
  310. )
  311. parser.add_argument(
  312. "--filter",
  313. dest="filter_spec",
  314. type=str,
  315. help="git-rev-list-style object filter",
  316. )
  317. parser.add_argument(
  318. "--protocol",
  319. type=int,
  320. help="Git protocol version to use",
  321. )
  322. parser.add_argument(
  323. "--recurse-submodules",
  324. action="store_true",
  325. help="Initialize and clone submodules",
  326. )
  327. parser.add_argument("source", help="Repository to clone from")
  328. parser.add_argument("target", nargs="?", help="Directory to clone into")
  329. args = parser.parse_args(args)
  330. try:
  331. porcelain.clone(
  332. args.source,
  333. args.target,
  334. bare=args.bare,
  335. depth=args.depth,
  336. branch=args.branch,
  337. refspec=args.refspec,
  338. filter_spec=args.filter_spec,
  339. protocol_version=args.protocol,
  340. recurse_submodules=args.recurse_submodules,
  341. )
  342. except GitProtocolError as e:
  343. print(f"{e}")
  344. class cmd_commit(Command):
  345. def run(self, args) -> None:
  346. parser = argparse.ArgumentParser()
  347. parser.add_argument("--message", "-m", required=True, help="Commit message")
  348. args = parser.parse_args(args)
  349. porcelain.commit(".", message=args.message)
  350. class cmd_commit_tree(Command):
  351. def run(self, args) -> None:
  352. parser = argparse.ArgumentParser()
  353. parser.add_argument("--message", "-m", required=True, help="Commit message")
  354. parser.add_argument("tree", help="Tree SHA to commit")
  355. args = parser.parse_args(args)
  356. porcelain.commit_tree(".", tree=args.tree, message=args.message)
  357. class cmd_update_server_info(Command):
  358. def run(self, args) -> None:
  359. porcelain.update_server_info(".")
  360. class cmd_symbolic_ref(Command):
  361. def run(self, args) -> None:
  362. parser = argparse.ArgumentParser()
  363. parser.add_argument("name", help="Symbolic reference name")
  364. parser.add_argument("ref", nargs="?", help="Target reference")
  365. parser.add_argument("--force", action="store_true", help="Force update")
  366. args = parser.parse_args(args)
  367. # If ref is provided, we're setting; otherwise we're reading
  368. if args.ref:
  369. # Set symbolic reference
  370. from .repo import Repo
  371. with Repo(".") as repo:
  372. repo.refs.set_symbolic_ref(args.name.encode(), args.ref.encode())
  373. else:
  374. # Read symbolic reference
  375. from .repo import Repo
  376. with Repo(".") as repo:
  377. try:
  378. target = repo.refs.read_ref(args.name.encode())
  379. if target.startswith(b"ref: "):
  380. print(target[5:].decode())
  381. else:
  382. print(target.decode())
  383. except KeyError:
  384. print(f"fatal: ref '{args.name}' is not a symbolic ref")
  385. class cmd_pack_refs(Command):
  386. def run(self, argv) -> None:
  387. parser = argparse.ArgumentParser()
  388. parser.add_argument("--all", action="store_true")
  389. # ignored, we never prune
  390. parser.add_argument("--no-prune", action="store_true")
  391. args = parser.parse_args(argv)
  392. porcelain.pack_refs(".", all=args.all)
  393. class cmd_show(Command):
  394. def run(self, argv) -> None:
  395. parser = argparse.ArgumentParser()
  396. parser.add_argument("objectish", type=str, nargs="*")
  397. args = parser.parse_args(argv)
  398. porcelain.show(".", args.objectish or None, outstream=sys.stdout)
  399. class cmd_diff_tree(Command):
  400. def run(self, args) -> None:
  401. parser = argparse.ArgumentParser()
  402. parser.add_argument("old_tree", help="Old tree SHA")
  403. parser.add_argument("new_tree", help="New tree SHA")
  404. args = parser.parse_args(args)
  405. porcelain.diff_tree(".", args.old_tree, args.new_tree)
  406. class cmd_rev_list(Command):
  407. def run(self, args) -> None:
  408. parser = argparse.ArgumentParser()
  409. parser.add_argument("commits", nargs="+", help="Commit IDs to list")
  410. args = parser.parse_args(args)
  411. porcelain.rev_list(".", args.commits)
  412. class cmd_tag(Command):
  413. def run(self, args) -> None:
  414. parser = argparse.ArgumentParser()
  415. parser.add_argument(
  416. "-a",
  417. "--annotated",
  418. help="Create an annotated tag.",
  419. action="store_true",
  420. )
  421. parser.add_argument(
  422. "-s", "--sign", help="Sign the annotated tag.", action="store_true"
  423. )
  424. parser.add_argument("tag_name", help="Name of the tag to create")
  425. args = parser.parse_args(args)
  426. porcelain.tag_create(
  427. ".", args.tag_name, annotated=args.annotated, sign=args.sign
  428. )
  429. class cmd_repack(Command):
  430. def run(self, args) -> None:
  431. parser = argparse.ArgumentParser()
  432. parser.parse_args(args)
  433. porcelain.repack(".")
  434. class cmd_reset(Command):
  435. def run(self, args) -> None:
  436. parser = argparse.ArgumentParser()
  437. mode_group = parser.add_mutually_exclusive_group()
  438. mode_group.add_argument(
  439. "--hard", action="store_true", help="Reset working tree and index"
  440. )
  441. mode_group.add_argument("--soft", action="store_true", help="Reset only HEAD")
  442. mode_group.add_argument(
  443. "--mixed", action="store_true", help="Reset HEAD and index"
  444. )
  445. parser.add_argument("treeish", nargs="?", help="Commit/tree to reset to")
  446. args = parser.parse_args(args)
  447. if args.hard:
  448. mode = "hard"
  449. elif args.soft:
  450. mode = "soft"
  451. elif args.mixed:
  452. mode = "mixed"
  453. else:
  454. # Default to mixed behavior
  455. mode = "mixed"
  456. # Use the porcelain.reset function for all modes
  457. porcelain.reset(".", mode=mode, treeish=args.treeish)
  458. class cmd_revert(Command):
  459. def run(self, args) -> None:
  460. parser = argparse.ArgumentParser()
  461. parser.add_argument(
  462. "--no-commit",
  463. "-n",
  464. action="store_true",
  465. help="Apply changes but don't create a commit",
  466. )
  467. parser.add_argument("-m", "--message", help="Custom commit message")
  468. parser.add_argument("commits", nargs="+", help="Commits to revert")
  469. args = parser.parse_args(args)
  470. result = porcelain.revert(
  471. ".", commits=args.commits, no_commit=args.no_commit, message=args.message
  472. )
  473. if result and not args.no_commit:
  474. print(f"[{result.decode('ascii')[:7]}] Revert completed")
  475. class cmd_daemon(Command):
  476. def run(self, args) -> None:
  477. from dulwich import log_utils
  478. from .protocol import TCP_GIT_PORT
  479. parser = argparse.ArgumentParser()
  480. parser.add_argument(
  481. "-l",
  482. "--listen_address",
  483. default="localhost",
  484. help="Binding IP address.",
  485. )
  486. parser.add_argument(
  487. "-p",
  488. "--port",
  489. type=int,
  490. default=TCP_GIT_PORT,
  491. help="Binding TCP port.",
  492. )
  493. parser.add_argument(
  494. "gitdir", nargs="?", default=".", help="Git directory to serve"
  495. )
  496. args = parser.parse_args(args)
  497. log_utils.default_logging_config()
  498. porcelain.daemon(args.gitdir, address=args.listen_address, port=args.port)
  499. class cmd_web_daemon(Command):
  500. def run(self, args) -> None:
  501. from dulwich import log_utils
  502. parser = argparse.ArgumentParser()
  503. parser.add_argument(
  504. "-l",
  505. "--listen_address",
  506. default="",
  507. help="Binding IP address.",
  508. )
  509. parser.add_argument(
  510. "-p",
  511. "--port",
  512. type=int,
  513. default=8000,
  514. help="Binding TCP port.",
  515. )
  516. parser.add_argument(
  517. "gitdir", nargs="?", default=".", help="Git directory to serve"
  518. )
  519. args = parser.parse_args(args)
  520. log_utils.default_logging_config()
  521. porcelain.web_daemon(args.gitdir, address=args.listen_address, port=args.port)
  522. class cmd_write_tree(Command):
  523. def run(self, args) -> None:
  524. parser = argparse.ArgumentParser()
  525. parser.parse_args(args)
  526. sys.stdout.write("{}\n".format(porcelain.write_tree(".").decode()))
  527. class cmd_receive_pack(Command):
  528. def run(self, args) -> None:
  529. parser = argparse.ArgumentParser()
  530. parser.add_argument("gitdir", nargs="?", default=".", help="Git directory")
  531. args = parser.parse_args(args)
  532. porcelain.receive_pack(args.gitdir)
  533. class cmd_upload_pack(Command):
  534. def run(self, args) -> None:
  535. parser = argparse.ArgumentParser()
  536. parser.add_argument("gitdir", nargs="?", default=".", help="Git directory")
  537. args = parser.parse_args(args)
  538. porcelain.upload_pack(args.gitdir)
  539. class cmd_status(Command):
  540. def run(self, args) -> None:
  541. parser = argparse.ArgumentParser()
  542. parser.add_argument("gitdir", nargs="?", default=".", help="Git directory")
  543. args = parser.parse_args(args)
  544. status = porcelain.status(args.gitdir)
  545. if any(names for (kind, names) in status.staged.items()):
  546. sys.stdout.write("Changes to be committed:\n\n")
  547. for kind, names in status.staged.items():
  548. for name in names:
  549. sys.stdout.write(
  550. f"\t{kind}: {name.decode(sys.getfilesystemencoding())}\n"
  551. )
  552. sys.stdout.write("\n")
  553. if status.unstaged:
  554. sys.stdout.write("Changes not staged for commit:\n\n")
  555. for name in status.unstaged:
  556. sys.stdout.write(f"\t{name.decode(sys.getfilesystemencoding())}\n")
  557. sys.stdout.write("\n")
  558. if status.untracked:
  559. sys.stdout.write("Untracked files:\n\n")
  560. for name in status.untracked:
  561. sys.stdout.write(f"\t{name}\n")
  562. sys.stdout.write("\n")
  563. class cmd_ls_remote(Command):
  564. def run(self, args) -> None:
  565. parser = argparse.ArgumentParser()
  566. parser.add_argument(
  567. "--symref", action="store_true", help="Show symbolic references"
  568. )
  569. parser.add_argument("url", help="Remote URL to list references from")
  570. args = parser.parse_args(args)
  571. result = porcelain.ls_remote(args.url)
  572. if args.symref:
  573. # Show symrefs first, like git does
  574. for ref, target in sorted(result.symrefs.items()):
  575. sys.stdout.write(f"ref: {target.decode()}\t{ref.decode()}\n")
  576. # Show regular refs
  577. for ref in sorted(result.refs):
  578. sys.stdout.write(f"{result.refs[ref].decode()}\t{ref.decode()}\n")
  579. class cmd_ls_tree(Command):
  580. def run(self, args) -> None:
  581. parser = argparse.ArgumentParser()
  582. parser.add_argument(
  583. "-r",
  584. "--recursive",
  585. action="store_true",
  586. help="Recursively list tree contents.",
  587. )
  588. parser.add_argument(
  589. "--name-only", action="store_true", help="Only display name."
  590. )
  591. parser.add_argument("treeish", nargs="?", help="Tree-ish to list")
  592. args = parser.parse_args(args)
  593. porcelain.ls_tree(
  594. ".",
  595. args.treeish,
  596. outstream=sys.stdout,
  597. recursive=args.recursive,
  598. name_only=args.name_only,
  599. )
  600. class cmd_pack_objects(Command):
  601. def run(self, args) -> None:
  602. parser = argparse.ArgumentParser()
  603. parser.add_argument(
  604. "--stdout", action="store_true", help="Write pack to stdout"
  605. )
  606. parser.add_argument("--deltify", action="store_true", help="Create deltas")
  607. parser.add_argument(
  608. "--no-reuse-deltas", action="store_true", help="Don't reuse existing deltas"
  609. )
  610. parser.add_argument("basename", nargs="?", help="Base name for pack files")
  611. args = parser.parse_args(args)
  612. if not args.stdout and not args.basename:
  613. parser.error("basename required when not using --stdout")
  614. object_ids = [line.strip() for line in sys.stdin.readlines()]
  615. deltify = args.deltify
  616. reuse_deltas = not args.no_reuse_deltas
  617. if args.stdout:
  618. packf = getattr(sys.stdout, "buffer", sys.stdout)
  619. idxf = None
  620. close = []
  621. else:
  622. packf = open(args.basename + ".pack", "wb")
  623. idxf = open(args.basename + ".idx", "wb")
  624. close = [packf, idxf]
  625. porcelain.pack_objects(
  626. ".", object_ids, packf, idxf, deltify=deltify, reuse_deltas=reuse_deltas
  627. )
  628. for f in close:
  629. f.close()
  630. class cmd_unpack_objects(Command):
  631. def run(self, args) -> None:
  632. parser = argparse.ArgumentParser()
  633. parser.add_argument("pack_file", help="Pack file to unpack")
  634. args = parser.parse_args(args)
  635. count = porcelain.unpack_objects(args.pack_file)
  636. print(f"Unpacked {count} objects")
  637. class cmd_prune(Command):
  638. def run(self, args) -> Optional[int]:
  639. import datetime
  640. import time
  641. from dulwich.object_store import DEFAULT_TEMPFILE_GRACE_PERIOD
  642. parser = argparse.ArgumentParser(
  643. description="Remove temporary pack files left behind by interrupted operations"
  644. )
  645. parser.add_argument(
  646. "--expire",
  647. nargs="?",
  648. const="2.weeks.ago",
  649. help="Only prune files older than the specified date (default: 2.weeks.ago)",
  650. )
  651. parser.add_argument(
  652. "--dry-run",
  653. "-n",
  654. action="store_true",
  655. help="Only report what would be removed",
  656. )
  657. parser.add_argument(
  658. "--verbose",
  659. "-v",
  660. action="store_true",
  661. help="Report all actions",
  662. )
  663. args = parser.parse_args(args)
  664. # Parse expire grace period
  665. grace_period = DEFAULT_TEMPFILE_GRACE_PERIOD
  666. if args.expire:
  667. try:
  668. grace_period = parse_relative_time(args.expire)
  669. except ValueError:
  670. # Try to parse as absolute date
  671. try:
  672. date = datetime.datetime.strptime(args.expire, "%Y-%m-%d")
  673. grace_period = int(time.time() - date.timestamp())
  674. except ValueError:
  675. print(f"Error: Invalid expire date: {args.expire}", file=sys.stderr)
  676. return 1
  677. # Progress callback
  678. def progress(msg):
  679. if args.verbose:
  680. print(msg)
  681. try:
  682. porcelain.prune(
  683. ".",
  684. grace_period=grace_period,
  685. dry_run=args.dry_run,
  686. progress=progress if args.verbose else None,
  687. )
  688. return None
  689. except porcelain.Error as e:
  690. print(f"Error: {e}", file=sys.stderr)
  691. return 1
  692. class cmd_pull(Command):
  693. def run(self, args) -> None:
  694. parser = argparse.ArgumentParser()
  695. parser.add_argument("from_location", type=str)
  696. parser.add_argument("refspec", type=str, nargs="*")
  697. parser.add_argument("--filter", type=str, nargs=1)
  698. parser.add_argument("--protocol", type=int)
  699. args = parser.parse_args(args)
  700. porcelain.pull(
  701. ".",
  702. args.from_location or None,
  703. args.refspec or None,
  704. filter_spec=args.filter,
  705. protocol_version=args.protocol or None,
  706. )
  707. class cmd_push(Command):
  708. def run(self, argv) -> Optional[int]:
  709. parser = argparse.ArgumentParser()
  710. parser.add_argument("-f", "--force", action="store_true", help="Force")
  711. parser.add_argument("to_location", type=str)
  712. parser.add_argument("refspec", type=str, nargs="*")
  713. args = parser.parse_args(argv)
  714. try:
  715. porcelain.push(
  716. ".", args.to_location, args.refspec or None, force=args.force
  717. )
  718. except porcelain.DivergedBranches:
  719. sys.stderr.write("Diverged branches; specify --force to override")
  720. return 1
  721. return None
  722. class cmd_remote_add(Command):
  723. def run(self, args) -> None:
  724. parser = argparse.ArgumentParser()
  725. parser.add_argument("name", help="Name of the remote")
  726. parser.add_argument("url", help="URL of the remote")
  727. args = parser.parse_args(args)
  728. porcelain.remote_add(".", args.name, args.url)
  729. class SuperCommand(Command):
  730. subcommands: ClassVar[dict[str, type[Command]]] = {}
  731. default_command: ClassVar[Optional[type[Command]]] = None
  732. def run(self, args):
  733. if not args:
  734. if self.default_command:
  735. return self.default_command().run(args)
  736. else:
  737. print(
  738. "Supported subcommands: {}".format(
  739. ", ".join(self.subcommands.keys())
  740. )
  741. )
  742. return False
  743. cmd = args[0]
  744. try:
  745. cmd_kls = self.subcommands[cmd]
  746. except KeyError:
  747. print(f"No such subcommand: {args[0]}")
  748. return False
  749. return cmd_kls().run(args[1:])
  750. class cmd_remote(SuperCommand):
  751. subcommands: ClassVar[dict[str, type[Command]]] = {
  752. "add": cmd_remote_add,
  753. }
  754. class cmd_submodule_list(Command):
  755. def run(self, argv) -> None:
  756. parser = argparse.ArgumentParser()
  757. parser.parse_args(argv)
  758. for path, sha in porcelain.submodule_list("."):
  759. sys.stdout.write(f" {sha} {path}\n")
  760. class cmd_submodule_init(Command):
  761. def run(self, argv) -> None:
  762. parser = argparse.ArgumentParser()
  763. parser.parse_args(argv)
  764. porcelain.submodule_init(".")
  765. class cmd_submodule_add(Command):
  766. def run(self, argv) -> None:
  767. parser = argparse.ArgumentParser()
  768. parser.add_argument("url", help="URL of repository to add as submodule")
  769. parser.add_argument("path", nargs="?", help="Path where submodule should live")
  770. parser.add_argument("--name", help="Name for the submodule")
  771. args = parser.parse_args(argv)
  772. porcelain.submodule_add(".", args.url, args.path, args.name)
  773. class cmd_submodule_update(Command):
  774. def run(self, argv) -> None:
  775. parser = argparse.ArgumentParser()
  776. parser.add_argument(
  777. "--init", action="store_true", help="Initialize submodules first"
  778. )
  779. parser.add_argument(
  780. "--force",
  781. action="store_true",
  782. help="Force update even if local changes exist",
  783. )
  784. parser.add_argument(
  785. "paths", nargs="*", help="Specific submodule paths to update"
  786. )
  787. args = parser.parse_args(argv)
  788. paths = args.paths if args.paths else None
  789. porcelain.submodule_update(".", paths=paths, init=args.init, force=args.force)
  790. class cmd_submodule(SuperCommand):
  791. subcommands: ClassVar[dict[str, type[Command]]] = {
  792. "add": cmd_submodule_add,
  793. "init": cmd_submodule_init,
  794. "list": cmd_submodule_list,
  795. "update": cmd_submodule_update,
  796. }
  797. default_command = cmd_submodule_list
  798. class cmd_check_ignore(Command):
  799. def run(self, args):
  800. parser = argparse.ArgumentParser()
  801. parser.add_argument("paths", nargs="+", help="Paths to check")
  802. args = parser.parse_args(args)
  803. ret = 1
  804. for path in porcelain.check_ignore(".", args.paths):
  805. print(path)
  806. ret = 0
  807. return ret
  808. class cmd_check_mailmap(Command):
  809. def run(self, args) -> None:
  810. parser = argparse.ArgumentParser()
  811. parser.add_argument("identities", nargs="+", help="Identities to check")
  812. args = parser.parse_args(args)
  813. for identity in args.identities:
  814. canonical_identity = porcelain.check_mailmap(".", identity)
  815. print(canonical_identity)
  816. class cmd_branch(Command):
  817. def run(self, args) -> Optional[int]:
  818. parser = argparse.ArgumentParser()
  819. parser.add_argument(
  820. "branch",
  821. type=str,
  822. help="Name of the branch",
  823. )
  824. parser.add_argument(
  825. "-d",
  826. "--delete",
  827. action="store_true",
  828. help="Delete branch",
  829. )
  830. args = parser.parse_args(args)
  831. if not args.branch:
  832. print("Usage: dulwich branch [-d] BRANCH_NAME")
  833. return 1
  834. if args.delete:
  835. porcelain.branch_delete(".", name=args.branch)
  836. else:
  837. try:
  838. porcelain.branch_create(".", name=args.branch)
  839. except porcelain.Error as e:
  840. sys.stderr.write(f"{e}")
  841. return 1
  842. return 0
  843. class cmd_checkout(Command):
  844. def run(self, args) -> Optional[int]:
  845. parser = argparse.ArgumentParser()
  846. parser.add_argument(
  847. "target",
  848. type=str,
  849. help="Name of the branch, tag, or commit to checkout",
  850. )
  851. parser.add_argument(
  852. "-f",
  853. "--force",
  854. action="store_true",
  855. help="Force checkout",
  856. )
  857. parser.add_argument(
  858. "-b",
  859. "--new-branch",
  860. type=str,
  861. help="Create a new branch at the target and switch to it",
  862. )
  863. args = parser.parse_args(args)
  864. if not args.target:
  865. print("Usage: dulwich checkout TARGET [--force] [-b NEW_BRANCH]")
  866. return 1
  867. try:
  868. porcelain.checkout(
  869. ".", target=args.target, force=args.force, new_branch=args.new_branch
  870. )
  871. except porcelain.CheckoutError as e:
  872. sys.stderr.write(f"{e}\n")
  873. return 1
  874. return 0
  875. class cmd_stash_list(Command):
  876. def run(self, args) -> None:
  877. parser = argparse.ArgumentParser()
  878. parser.parse_args(args)
  879. for i, entry in porcelain.stash_list("."):
  880. print("stash@{{{}}}: {}".format(i, entry.message.rstrip("\n")))
  881. class cmd_stash_push(Command):
  882. def run(self, args) -> None:
  883. parser = argparse.ArgumentParser()
  884. parser.parse_args(args)
  885. porcelain.stash_push(".")
  886. print("Saved working directory and index state")
  887. class cmd_stash_pop(Command):
  888. def run(self, args) -> None:
  889. parser = argparse.ArgumentParser()
  890. parser.parse_args(args)
  891. porcelain.stash_pop(".")
  892. print("Restored working directory and index state")
  893. class cmd_stash(SuperCommand):
  894. subcommands: ClassVar[dict[str, type[Command]]] = {
  895. "list": cmd_stash_list,
  896. "pop": cmd_stash_pop,
  897. "push": cmd_stash_push,
  898. }
  899. class cmd_ls_files(Command):
  900. def run(self, args) -> None:
  901. parser = argparse.ArgumentParser()
  902. parser.parse_args(args)
  903. for name in porcelain.ls_files("."):
  904. print(name)
  905. class cmd_describe(Command):
  906. def run(self, args) -> None:
  907. parser = argparse.ArgumentParser()
  908. parser.parse_args(args)
  909. print(porcelain.describe("."))
  910. class cmd_merge(Command):
  911. def run(self, args) -> Optional[int]:
  912. parser = argparse.ArgumentParser()
  913. parser.add_argument("commit", type=str, help="Commit to merge")
  914. parser.add_argument(
  915. "--no-commit", action="store_true", help="Do not create a merge commit"
  916. )
  917. parser.add_argument(
  918. "--no-ff", action="store_true", help="Force create a merge commit"
  919. )
  920. parser.add_argument("-m", "--message", type=str, help="Merge commit message")
  921. args = parser.parse_args(args)
  922. try:
  923. merge_commit_id, conflicts = porcelain.merge(
  924. ".",
  925. args.commit,
  926. no_commit=args.no_commit,
  927. no_ff=args.no_ff,
  928. message=args.message,
  929. )
  930. if conflicts:
  931. print(f"Merge conflicts in {len(conflicts)} file(s):")
  932. for conflict_path in conflicts:
  933. print(f" {conflict_path.decode()}")
  934. print(
  935. "\nAutomatic merge failed; fix conflicts and then commit the result."
  936. )
  937. return 1
  938. elif merge_commit_id is None and not args.no_commit:
  939. print("Already up to date.")
  940. elif args.no_commit:
  941. print("Automatic merge successful; not committing as requested.")
  942. else:
  943. print(
  944. f"Merge successful. Created merge commit {merge_commit_id.decode()}"
  945. )
  946. return 0
  947. except porcelain.Error as e:
  948. print(f"Error: {e}")
  949. return 1
  950. class cmd_notes_add(Command):
  951. def run(self, args) -> None:
  952. parser = argparse.ArgumentParser()
  953. parser.add_argument("object", help="Object to annotate")
  954. parser.add_argument("-m", "--message", help="Note message", required=True)
  955. parser.add_argument(
  956. "--ref", default="commits", help="Notes ref (default: commits)"
  957. )
  958. args = parser.parse_args(args)
  959. porcelain.notes_add(".", args.object, args.message, ref=args.ref)
  960. class cmd_notes_show(Command):
  961. def run(self, args) -> None:
  962. parser = argparse.ArgumentParser()
  963. parser.add_argument("object", help="Object to show notes for")
  964. parser.add_argument(
  965. "--ref", default="commits", help="Notes ref (default: commits)"
  966. )
  967. args = parser.parse_args(args)
  968. note = porcelain.notes_show(".", args.object, ref=args.ref)
  969. if note:
  970. sys.stdout.buffer.write(note)
  971. else:
  972. print(f"No notes found for object {args.object}")
  973. class cmd_notes_remove(Command):
  974. def run(self, args) -> None:
  975. parser = argparse.ArgumentParser()
  976. parser.add_argument("object", help="Object to remove notes from")
  977. parser.add_argument(
  978. "--ref", default="commits", help="Notes ref (default: commits)"
  979. )
  980. args = parser.parse_args(args)
  981. result = porcelain.notes_remove(".", args.object, ref=args.ref)
  982. if result:
  983. print(f"Removed notes for object {args.object}")
  984. else:
  985. print(f"No notes found for object {args.object}")
  986. class cmd_notes_list(Command):
  987. def run(self, args) -> None:
  988. parser = argparse.ArgumentParser()
  989. parser.add_argument(
  990. "--ref", default="commits", help="Notes ref (default: commits)"
  991. )
  992. args = parser.parse_args(args)
  993. notes = porcelain.notes_list(".", ref=args.ref)
  994. for object_sha, note_content in notes:
  995. print(f"{object_sha.hex()}")
  996. class cmd_notes(SuperCommand):
  997. subcommands: ClassVar[dict[str, type[Command]]] = {
  998. "add": cmd_notes_add,
  999. "show": cmd_notes_show,
  1000. "remove": cmd_notes_remove,
  1001. "list": cmd_notes_list,
  1002. }
  1003. default_command = cmd_notes_list
  1004. class cmd_cherry_pick(Command):
  1005. def run(self, args) -> Optional[int]:
  1006. parser = argparse.ArgumentParser(
  1007. description="Apply the changes introduced by some existing commits"
  1008. )
  1009. parser.add_argument("commit", nargs="?", help="Commit to cherry-pick")
  1010. parser.add_argument(
  1011. "-n",
  1012. "--no-commit",
  1013. action="store_true",
  1014. help="Apply changes without making a commit",
  1015. )
  1016. parser.add_argument(
  1017. "--continue",
  1018. dest="continue_",
  1019. action="store_true",
  1020. help="Continue after resolving conflicts",
  1021. )
  1022. parser.add_argument(
  1023. "--abort",
  1024. action="store_true",
  1025. help="Abort the current cherry-pick operation",
  1026. )
  1027. args = parser.parse_args(args)
  1028. # Check argument validity
  1029. if args.continue_ or args.abort:
  1030. if args.commit is not None:
  1031. parser.error("Cannot specify commit with --continue or --abort")
  1032. return 1
  1033. else:
  1034. if args.commit is None:
  1035. parser.error("Commit argument is required")
  1036. return 1
  1037. try:
  1038. commit_arg = args.commit
  1039. result = porcelain.cherry_pick(
  1040. ".",
  1041. commit_arg,
  1042. no_commit=args.no_commit,
  1043. continue_=args.continue_,
  1044. abort=args.abort,
  1045. )
  1046. if args.abort:
  1047. print("Cherry-pick aborted.")
  1048. elif args.continue_:
  1049. if result:
  1050. print(f"Cherry-pick completed: {result.decode()}")
  1051. else:
  1052. print("Cherry-pick completed.")
  1053. elif result is None:
  1054. if args.no_commit:
  1055. print("Cherry-pick applied successfully (no commit created).")
  1056. else:
  1057. # This shouldn't happen unless there were conflicts
  1058. print("Cherry-pick resulted in conflicts.")
  1059. else:
  1060. print(f"Cherry-pick successful: {result.decode()}")
  1061. return None
  1062. except porcelain.Error as e:
  1063. print(f"Error: {e}", file=sys.stderr)
  1064. return 1
  1065. class cmd_merge_tree(Command):
  1066. def run(self, args) -> Optional[int]:
  1067. parser = argparse.ArgumentParser(
  1068. description="Perform a tree-level merge without touching the working directory"
  1069. )
  1070. parser.add_argument(
  1071. "base_tree",
  1072. nargs="?",
  1073. help="The common ancestor tree (optional, defaults to empty tree)",
  1074. )
  1075. parser.add_argument("our_tree", help="Our side of the merge")
  1076. parser.add_argument("their_tree", help="Their side of the merge")
  1077. parser.add_argument(
  1078. "-z",
  1079. "--name-only",
  1080. action="store_true",
  1081. help="Output only conflict paths, null-terminated",
  1082. )
  1083. args = parser.parse_args(args)
  1084. try:
  1085. # Determine base tree - if only two args provided, base is None
  1086. if args.base_tree is None:
  1087. # Only two arguments provided
  1088. base_tree = None
  1089. our_tree = args.our_tree
  1090. their_tree = args.their_tree
  1091. else:
  1092. # Three arguments provided
  1093. base_tree = args.base_tree
  1094. our_tree = args.our_tree
  1095. their_tree = args.their_tree
  1096. merged_tree_id, conflicts = porcelain.merge_tree(
  1097. ".", base_tree, our_tree, their_tree
  1098. )
  1099. if args.name_only:
  1100. # Output only conflict paths, null-terminated
  1101. for conflict_path in conflicts:
  1102. sys.stdout.buffer.write(conflict_path)
  1103. sys.stdout.buffer.write(b"\0")
  1104. else:
  1105. # Output the merged tree SHA
  1106. print(merged_tree_id.decode("ascii"))
  1107. # Output conflict information
  1108. if conflicts:
  1109. print(f"\nConflicts in {len(conflicts)} file(s):")
  1110. for conflict_path in conflicts:
  1111. print(f" {conflict_path.decode()}")
  1112. return None
  1113. except porcelain.Error as e:
  1114. print(f"Error: {e}", file=sys.stderr)
  1115. return 1
  1116. except KeyError as e:
  1117. print(f"Error: Object not found: {e}", file=sys.stderr)
  1118. return 1
  1119. class cmd_gc(Command):
  1120. def run(self, args) -> Optional[int]:
  1121. import datetime
  1122. import time
  1123. parser = argparse.ArgumentParser()
  1124. parser.add_argument(
  1125. "--auto",
  1126. action="store_true",
  1127. help="Only run gc if needed",
  1128. )
  1129. parser.add_argument(
  1130. "--aggressive",
  1131. action="store_true",
  1132. help="Use more aggressive settings",
  1133. )
  1134. parser.add_argument(
  1135. "--no-prune",
  1136. action="store_true",
  1137. help="Do not prune unreachable objects",
  1138. )
  1139. parser.add_argument(
  1140. "--prune",
  1141. nargs="?",
  1142. const="now",
  1143. help="Prune unreachable objects older than date (default: 2 weeks ago)",
  1144. )
  1145. parser.add_argument(
  1146. "--dry-run",
  1147. "-n",
  1148. action="store_true",
  1149. help="Only report what would be done",
  1150. )
  1151. parser.add_argument(
  1152. "--quiet",
  1153. "-q",
  1154. action="store_true",
  1155. help="Only report errors",
  1156. )
  1157. args = parser.parse_args(args)
  1158. # Parse prune grace period
  1159. grace_period = None
  1160. if args.prune:
  1161. try:
  1162. grace_period = parse_relative_time(args.prune)
  1163. except ValueError:
  1164. # Try to parse as absolute date
  1165. try:
  1166. date = datetime.datetime.strptime(args.prune, "%Y-%m-%d")
  1167. grace_period = int(time.time() - date.timestamp())
  1168. except ValueError:
  1169. print(f"Error: Invalid prune date: {args.prune}")
  1170. return 1
  1171. elif not args.no_prune:
  1172. # Default to 2 weeks
  1173. grace_period = 1209600
  1174. # Progress callback
  1175. def progress(msg):
  1176. if not args.quiet:
  1177. print(msg)
  1178. try:
  1179. stats = porcelain.gc(
  1180. ".",
  1181. auto=args.auto,
  1182. aggressive=args.aggressive,
  1183. prune=not args.no_prune,
  1184. grace_period=grace_period,
  1185. dry_run=args.dry_run,
  1186. progress=progress if not args.quiet else None,
  1187. )
  1188. # Report results
  1189. if not args.quiet:
  1190. if args.dry_run:
  1191. print("\nDry run results:")
  1192. else:
  1193. print("\nGarbage collection complete:")
  1194. if stats.pruned_objects:
  1195. print(f" Pruned {len(stats.pruned_objects)} unreachable objects")
  1196. print(f" Freed {format_bytes(stats.bytes_freed)}")
  1197. if stats.packs_before != stats.packs_after:
  1198. print(
  1199. f" Reduced pack files from {stats.packs_before} to {stats.packs_after}"
  1200. )
  1201. except porcelain.Error as e:
  1202. print(f"Error: {e}")
  1203. return 1
  1204. return None
  1205. class cmd_count_objects(Command):
  1206. def run(self, args) -> None:
  1207. parser = argparse.ArgumentParser()
  1208. parser.add_argument(
  1209. "-v",
  1210. "--verbose",
  1211. action="store_true",
  1212. help="Display verbose information.",
  1213. )
  1214. args = parser.parse_args(args)
  1215. if args.verbose:
  1216. stats = porcelain.count_objects(".", verbose=True)
  1217. # Display verbose output
  1218. print(f"count: {stats.count}")
  1219. print(f"size: {stats.size // 1024}") # Size in KiB
  1220. assert stats.in_pack is not None
  1221. print(f"in-pack: {stats.in_pack}")
  1222. assert stats.packs is not None
  1223. print(f"packs: {stats.packs}")
  1224. assert stats.size_pack is not None
  1225. print(f"size-pack: {stats.size_pack // 1024}") # Size in KiB
  1226. else:
  1227. # Simple output
  1228. stats = porcelain.count_objects(".", verbose=False)
  1229. print(f"{stats.count} objects, {stats.size // 1024} kilobytes")
  1230. class cmd_rebase(Command):
  1231. def run(self, args) -> int:
  1232. parser = argparse.ArgumentParser()
  1233. parser.add_argument(
  1234. "upstream", nargs="?", help="Upstream branch to rebase onto"
  1235. )
  1236. parser.add_argument("--onto", type=str, help="Rebase onto specific commit")
  1237. parser.add_argument(
  1238. "--branch", type=str, help="Branch to rebase (default: current)"
  1239. )
  1240. parser.add_argument(
  1241. "--abort", action="store_true", help="Abort an in-progress rebase"
  1242. )
  1243. parser.add_argument(
  1244. "--continue",
  1245. dest="continue_rebase",
  1246. action="store_true",
  1247. help="Continue an in-progress rebase",
  1248. )
  1249. parser.add_argument(
  1250. "--skip", action="store_true", help="Skip current commit and continue"
  1251. )
  1252. args = parser.parse_args(args)
  1253. # Handle abort/continue/skip first
  1254. if args.abort:
  1255. try:
  1256. porcelain.rebase(".", args.upstream or "HEAD", abort=True)
  1257. print("Rebase aborted.")
  1258. except porcelain.Error as e:
  1259. print(f"Error: {e}")
  1260. return 1
  1261. return 0
  1262. if args.continue_rebase:
  1263. try:
  1264. new_shas = porcelain.rebase(
  1265. ".", args.upstream or "HEAD", continue_rebase=True
  1266. )
  1267. print("Rebase complete.")
  1268. except porcelain.Error as e:
  1269. print(f"Error: {e}")
  1270. return 1
  1271. return 0
  1272. # Normal rebase requires upstream
  1273. if not args.upstream:
  1274. print("Error: Missing required argument 'upstream'")
  1275. return 1
  1276. try:
  1277. new_shas = porcelain.rebase(
  1278. ".",
  1279. args.upstream,
  1280. onto=args.onto,
  1281. branch=args.branch,
  1282. )
  1283. if new_shas:
  1284. print(f"Successfully rebased {len(new_shas)} commits.")
  1285. else:
  1286. print("Already up to date.")
  1287. return 0
  1288. except porcelain.Error as e:
  1289. print(f"Error: {e}")
  1290. return 1
  1291. class cmd_filter_branch(Command):
  1292. def run(self, args) -> Optional[int]:
  1293. import subprocess
  1294. parser = argparse.ArgumentParser(description="Rewrite branches")
  1295. # Supported Git-compatible options
  1296. parser.add_argument(
  1297. "--subdirectory-filter",
  1298. type=str,
  1299. help="Only include history for subdirectory",
  1300. )
  1301. parser.add_argument("--env-filter", type=str, help="Environment filter command")
  1302. parser.add_argument("--tree-filter", type=str, help="Tree filter command")
  1303. parser.add_argument("--index-filter", type=str, help="Index filter command")
  1304. parser.add_argument("--parent-filter", type=str, help="Parent filter command")
  1305. parser.add_argument("--msg-filter", type=str, help="Message filter command")
  1306. parser.add_argument("--commit-filter", type=str, help="Commit filter command")
  1307. parser.add_argument(
  1308. "--tag-name-filter", type=str, help="Tag name filter command"
  1309. )
  1310. parser.add_argument(
  1311. "--prune-empty", action="store_true", help="Remove empty commits"
  1312. )
  1313. parser.add_argument(
  1314. "--original",
  1315. type=str,
  1316. default="refs/original",
  1317. help="Namespace for original refs",
  1318. )
  1319. parser.add_argument(
  1320. "-f",
  1321. "--force",
  1322. action="store_true",
  1323. help="Force operation even if refs/original/* exists",
  1324. )
  1325. # Branch/ref to rewrite (defaults to HEAD)
  1326. parser.add_argument(
  1327. "branch", nargs="?", default="HEAD", help="Branch or ref to rewrite"
  1328. )
  1329. args = parser.parse_args(args)
  1330. # Track if any filter fails
  1331. filter_error = False
  1332. # Setup environment for filters
  1333. env = os.environ.copy()
  1334. # Helper function to run shell commands
  1335. def run_filter(cmd, input_data=None, cwd=None, extra_env=None):
  1336. nonlocal filter_error
  1337. filter_env = env.copy()
  1338. if extra_env:
  1339. filter_env.update(extra_env)
  1340. result = subprocess.run(
  1341. cmd,
  1342. shell=True,
  1343. input=input_data,
  1344. cwd=cwd,
  1345. env=filter_env,
  1346. capture_output=True,
  1347. )
  1348. if result.returncode != 0:
  1349. filter_error = True
  1350. return None
  1351. return result.stdout
  1352. # Create filter functions based on arguments
  1353. filter_message = None
  1354. if args.msg_filter:
  1355. def filter_message(message):
  1356. result = run_filter(args.msg_filter, input_data=message)
  1357. return result if result is not None else message
  1358. tree_filter = None
  1359. if args.tree_filter:
  1360. def tree_filter(tree_sha, tmpdir):
  1361. from dulwich.objects import Blob, Tree
  1362. # Export tree to tmpdir
  1363. with Repo(".") as r:
  1364. tree = r.object_store[tree_sha]
  1365. for entry in tree.items():
  1366. path = Path(tmpdir) / entry.path.decode()
  1367. if entry.mode & 0o040000: # Directory
  1368. path.mkdir(exist_ok=True)
  1369. else:
  1370. obj = r.object_store[entry.sha]
  1371. path.write_bytes(obj.data)
  1372. # Run the filter command in the temp directory
  1373. run_filter(args.tree_filter, cwd=tmpdir)
  1374. # Rebuild tree from modified temp directory
  1375. def build_tree_from_dir(dir_path):
  1376. tree = Tree()
  1377. for name in sorted(os.listdir(dir_path)):
  1378. if name.startswith("."):
  1379. continue
  1380. path = os.path.join(dir_path, name)
  1381. if os.path.isdir(path):
  1382. subtree_sha = build_tree_from_dir(path)
  1383. tree.add(name.encode(), 0o040000, subtree_sha)
  1384. else:
  1385. with open(path, "rb") as f:
  1386. data = f.read()
  1387. blob = Blob.from_string(data)
  1388. r.object_store.add_object(blob)
  1389. # Use appropriate file mode
  1390. mode = os.stat(path).st_mode
  1391. if mode & 0o100:
  1392. file_mode = 0o100755
  1393. else:
  1394. file_mode = 0o100644
  1395. tree.add(name.encode(), file_mode, blob.id)
  1396. r.object_store.add_object(tree)
  1397. return tree.id
  1398. return build_tree_from_dir(tmpdir)
  1399. index_filter = None
  1400. if args.index_filter:
  1401. def index_filter(tree_sha, index_path):
  1402. run_filter(args.index_filter, extra_env={"GIT_INDEX_FILE": index_path})
  1403. return None # Read back from index
  1404. parent_filter = None
  1405. if args.parent_filter:
  1406. def parent_filter(parents):
  1407. parent_str = " ".join(p.hex() for p in parents)
  1408. result = run_filter(args.parent_filter, input_data=parent_str.encode())
  1409. if result is None:
  1410. return parents
  1411. output = result.decode().strip()
  1412. if not output:
  1413. return []
  1414. new_parents = []
  1415. for sha in output.split():
  1416. if valid_hexsha(sha):
  1417. new_parents.append(sha)
  1418. return new_parents
  1419. commit_filter = None
  1420. if args.commit_filter:
  1421. def commit_filter(commit_obj, tree_sha):
  1422. # The filter receives: tree parent1 parent2...
  1423. cmd_input = tree_sha.hex()
  1424. for parent in commit_obj.parents:
  1425. cmd_input += " " + parent.hex()
  1426. result = run_filter(
  1427. args.commit_filter,
  1428. input_data=cmd_input.encode(),
  1429. extra_env={"GIT_COMMIT": commit_obj.id.hex()},
  1430. )
  1431. if result is None:
  1432. return None
  1433. output = result.decode().strip()
  1434. if not output:
  1435. return None # Skip commit
  1436. if valid_hexsha(output):
  1437. return output
  1438. return None
  1439. tag_name_filter = None
  1440. if args.tag_name_filter:
  1441. def tag_name_filter(tag_name):
  1442. result = run_filter(args.tag_name_filter, input_data=tag_name)
  1443. return result.strip() if result is not None else tag_name
  1444. # Open repo once
  1445. with Repo(".") as r:
  1446. # Check for refs/original if not forcing
  1447. if not args.force:
  1448. original_prefix = args.original.encode() + b"/"
  1449. for ref in r.refs.allkeys():
  1450. if ref.startswith(original_prefix):
  1451. print("Cannot create a new backup.")
  1452. print(f"A previous backup already exists in {args.original}/")
  1453. print("Force overwriting the backup with -f")
  1454. return 1
  1455. try:
  1456. # Call porcelain.filter_branch with the repo object
  1457. result = porcelain.filter_branch(
  1458. r,
  1459. args.branch,
  1460. filter_message=filter_message,
  1461. tree_filter=tree_filter if args.tree_filter else None,
  1462. index_filter=index_filter if args.index_filter else None,
  1463. parent_filter=parent_filter if args.parent_filter else None,
  1464. commit_filter=commit_filter if args.commit_filter else None,
  1465. subdirectory_filter=args.subdirectory_filter,
  1466. prune_empty=args.prune_empty,
  1467. tag_name_filter=tag_name_filter if args.tag_name_filter else None,
  1468. force=args.force,
  1469. keep_original=True, # Always keep original with git
  1470. )
  1471. # Check if any filter failed
  1472. if filter_error:
  1473. print("Error: Filter command failed", file=sys.stderr)
  1474. return 1
  1475. # Git filter-branch shows progress
  1476. if result:
  1477. print(f"Rewrite {args.branch} ({len(result)} commits)")
  1478. # Git shows: Ref 'refs/heads/branch' was rewritten
  1479. if args.branch != "HEAD":
  1480. ref_name = (
  1481. args.branch
  1482. if args.branch.startswith("refs/")
  1483. else f"refs/heads/{args.branch}"
  1484. )
  1485. print(f"Ref '{ref_name}' was rewritten")
  1486. return 0
  1487. except porcelain.Error as e:
  1488. print(f"Error: {e}", file=sys.stderr)
  1489. return 1
  1490. class cmd_help(Command):
  1491. def run(self, args) -> None:
  1492. parser = argparse.ArgumentParser()
  1493. parser.add_argument(
  1494. "-a",
  1495. "--all",
  1496. action="store_true",
  1497. help="List all commands.",
  1498. )
  1499. args = parser.parse_args(args)
  1500. if args.all:
  1501. print("Available commands:")
  1502. for cmd in sorted(commands):
  1503. print(f" {cmd}")
  1504. else:
  1505. print(
  1506. """\
  1507. The dulwich command line tool is currently a very basic frontend for the
  1508. Dulwich python module. For full functionality, please see the API reference.
  1509. For a list of supported commands, see 'dulwich help -a'.
  1510. """
  1511. )
  1512. commands = {
  1513. "add": cmd_add,
  1514. "annotate": cmd_annotate,
  1515. "archive": cmd_archive,
  1516. "blame": cmd_blame,
  1517. "branch": cmd_branch,
  1518. "check-ignore": cmd_check_ignore,
  1519. "check-mailmap": cmd_check_mailmap,
  1520. "checkout": cmd_checkout,
  1521. "cherry-pick": cmd_cherry_pick,
  1522. "clone": cmd_clone,
  1523. "commit": cmd_commit,
  1524. "commit-tree": cmd_commit_tree,
  1525. "count-objects": cmd_count_objects,
  1526. "describe": cmd_describe,
  1527. "daemon": cmd_daemon,
  1528. "diff": cmd_diff,
  1529. "diff-tree": cmd_diff_tree,
  1530. "dump-pack": cmd_dump_pack,
  1531. "dump-index": cmd_dump_index,
  1532. "fetch-pack": cmd_fetch_pack,
  1533. "fetch": cmd_fetch,
  1534. "filter-branch": cmd_filter_branch,
  1535. "for-each-ref": cmd_for_each_ref,
  1536. "fsck": cmd_fsck,
  1537. "gc": cmd_gc,
  1538. "help": cmd_help,
  1539. "init": cmd_init,
  1540. "log": cmd_log,
  1541. "ls-files": cmd_ls_files,
  1542. "ls-remote": cmd_ls_remote,
  1543. "ls-tree": cmd_ls_tree,
  1544. "merge": cmd_merge,
  1545. "merge-tree": cmd_merge_tree,
  1546. "notes": cmd_notes,
  1547. "pack-objects": cmd_pack_objects,
  1548. "pack-refs": cmd_pack_refs,
  1549. "prune": cmd_prune,
  1550. "pull": cmd_pull,
  1551. "push": cmd_push,
  1552. "rebase": cmd_rebase,
  1553. "receive-pack": cmd_receive_pack,
  1554. "remote": cmd_remote,
  1555. "repack": cmd_repack,
  1556. "reset": cmd_reset,
  1557. "revert": cmd_revert,
  1558. "rev-list": cmd_rev_list,
  1559. "rm": cmd_rm,
  1560. "mv": cmd_mv,
  1561. "show": cmd_show,
  1562. "stash": cmd_stash,
  1563. "status": cmd_status,
  1564. "symbolic-ref": cmd_symbolic_ref,
  1565. "submodule": cmd_submodule,
  1566. "tag": cmd_tag,
  1567. "unpack-objects": cmd_unpack_objects,
  1568. "update-server-info": cmd_update_server_info,
  1569. "upload-pack": cmd_upload_pack,
  1570. "web-daemon": cmd_web_daemon,
  1571. "write-tree": cmd_write_tree,
  1572. }
  1573. def main(argv=None) -> Optional[int]:
  1574. if argv is None:
  1575. argv = sys.argv[1:]
  1576. if len(argv) < 1:
  1577. print("Usage: dulwich <{}> [OPTIONS...]".format("|".join(commands.keys())))
  1578. return 1
  1579. cmd = argv[0]
  1580. try:
  1581. cmd_kls = commands[cmd]
  1582. except KeyError:
  1583. print(f"No such subcommand: {cmd}")
  1584. return 1
  1585. # TODO(jelmer): Return non-0 on errors
  1586. return cmd_kls().run(argv[1:])
  1587. def _main() -> None:
  1588. if "DULWICH_PDB" in os.environ and getattr(signal, "SIGQUIT", None):
  1589. signal.signal(signal.SIGQUIT, signal_quit) # type: ignore
  1590. signal.signal(signal.SIGINT, signal_int)
  1591. sys.exit(main())
  1592. if __name__ == "__main__":
  1593. _main()