porcelain.py 112 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882288328842885288628872888288928902891289228932894289528962897289828992900290129022903290429052906290729082909291029112912291329142915291629172918291929202921292229232924292529262927292829292930293129322933293429352936293729382939294029412942294329442945294629472948294929502951295229532954295529562957295829592960296129622963296429652966296729682969297029712972297329742975297629772978297929802981298229832984298529862987298829892990299129922993299429952996299729982999300030013002300330043005300630073008300930103011301230133014301530163017301830193020302130223023302430253026302730283029303030313032303330343035303630373038303930403041304230433044304530463047304830493050305130523053305430553056305730583059306030613062306330643065306630673068306930703071307230733074307530763077307830793080308130823083308430853086308730883089309030913092309330943095309630973098309931003101310231033104310531063107310831093110311131123113311431153116311731183119312031213122312331243125312631273128312931303131313231333134313531363137313831393140314131423143314431453146314731483149315031513152315331543155315631573158315931603161316231633164316531663167316831693170317131723173317431753176317731783179318031813182318331843185318631873188318931903191319231933194319531963197319831993200320132023203320432053206320732083209321032113212321332143215321632173218321932203221322232233224322532263227322832293230323132323233323432353236323732383239324032413242324332443245324632473248324932503251325232533254325532563257325832593260326132623263326432653266326732683269327032713272327332743275327632773278327932803281328232833284328532863287328832893290329132923293329432953296329732983299330033013302330333043305330633073308330933103311331233133314331533163317331833193320332133223323332433253326332733283329333033313332333333343335333633373338333933403341334233433344334533463347334833493350335133523353335433553356335733583359336033613362336333643365336633673368336933703371337233733374337533763377337833793380338133823383338433853386338733883389339033913392339333943395339633973398339934003401340234033404340534063407340834093410341134123413341434153416341734183419342034213422342334243425342634273428342934303431343234333434343534363437343834393440344134423443344434453446344734483449345034513452345334543455345634573458345934603461346234633464346534663467346834693470347134723473347434753476347734783479348034813482348334843485348634873488348934903491349234933494349534963497349834993500350135023503350435053506350735083509351035113512351335143515351635173518351935203521352235233524352535263527352835293530353135323533353435353536353735383539354035413542
  1. # porcelain.py -- Porcelain-like layer on top of Dulwich
  2. # Copyright (C) 2013 Jelmer Vernooij <jelmer@jelmer.uk>
  3. #
  4. # SPDX-License-Identifier: Apache-2.0 OR GPL-2.0-or-later
  5. # Dulwich is dual-licensed under the Apache License, Version 2.0 and the GNU
  6. # General Public License as public by the Free Software Foundation; version 2.0
  7. # or (at your option) any later version. You can redistribute it and/or
  8. # modify it under the terms of either of these two licenses.
  9. #
  10. # Unless required by applicable law or agreed to in writing, software
  11. # distributed under the License is distributed on an "AS IS" BASIS,
  12. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13. # See the License for the specific language governing permissions and
  14. # limitations under the License.
  15. #
  16. # You should have received a copy of the licenses; if not, see
  17. # <http://www.gnu.org/licenses/> for a copy of the GNU General Public License
  18. # and <http://www.apache.org/licenses/LICENSE-2.0> for a copy of the Apache
  19. # License, Version 2.0.
  20. #
  21. """Simple wrapper that provides porcelain-like functions on top of Dulwich.
  22. Currently implemented:
  23. * archive
  24. * add
  25. * branch{_create,_delete,_list}
  26. * check_ignore
  27. * checkout
  28. * checkout_branch
  29. * clone
  30. * cone mode{_init, _set, _add}
  31. * commit
  32. * commit_tree
  33. * daemon
  34. * describe
  35. * diff_tree
  36. * fetch
  37. * for_each_ref
  38. * init
  39. * ls_files
  40. * ls_remote
  41. * ls_tree
  42. * merge
  43. * merge_tree
  44. * pull
  45. * push
  46. * rm
  47. * remote{_add}
  48. * receive_pack
  49. * reset
  50. * revert
  51. * sparse_checkout
  52. * submodule_add
  53. * submodule_init
  54. * submodule_list
  55. * rev_list
  56. * tag{_create,_delete,_list}
  57. * upload_pack
  58. * update_server_info
  59. * status
  60. * symbolic_ref
  61. These functions are meant to behave similarly to the git subcommands.
  62. Differences in behaviour are considered bugs.
  63. Note: one of the consequences of this is that paths tend to be
  64. interpreted relative to the current working directory rather than relative
  65. to the repository root.
  66. Functions should generally accept both unicode strings and bytestrings
  67. """
  68. import datetime
  69. import fnmatch
  70. import os
  71. import posixpath
  72. import stat
  73. import sys
  74. import time
  75. from collections import namedtuple
  76. from contextlib import closing, contextmanager
  77. from dataclasses import dataclass
  78. from io import BytesIO, RawIOBase
  79. from pathlib import Path
  80. from typing import Optional, Union
  81. from . import replace_me
  82. from .archive import tar_stream
  83. from .client import get_transport_and_path
  84. from .config import Config, ConfigFile, StackedConfig, read_submodules
  85. from .diff_tree import (
  86. CHANGE_ADD,
  87. CHANGE_COPY,
  88. CHANGE_DELETE,
  89. CHANGE_MODIFY,
  90. CHANGE_RENAME,
  91. RENAME_CHANGE_TYPES,
  92. )
  93. from .errors import SendPackError
  94. from .graph import can_fast_forward
  95. from .ignore import IgnoreFilterManager
  96. from .index import (
  97. _fs_to_tree_path,
  98. blob_from_path_and_stat,
  99. build_file_from_blob,
  100. get_unstaged_changes,
  101. update_working_tree,
  102. )
  103. from .object_store import tree_lookup_path
  104. from .objects import (
  105. Commit,
  106. Tag,
  107. format_timezone,
  108. parse_timezone,
  109. pretty_format_tree_entry,
  110. )
  111. from .objectspec import (
  112. parse_commit,
  113. parse_object,
  114. parse_ref,
  115. parse_reftuples,
  116. parse_tree,
  117. )
  118. from .pack import write_pack_from_container, write_pack_index
  119. from .patch import write_tree_diff
  120. from .protocol import ZERO_SHA, Protocol
  121. from .refs import (
  122. LOCAL_BRANCH_PREFIX,
  123. LOCAL_NOTES_PREFIX,
  124. LOCAL_TAG_PREFIX,
  125. Ref,
  126. _import_remote_refs,
  127. )
  128. from .repo import BaseRepo, Repo, get_user_identity
  129. from .server import (
  130. FileSystemBackend,
  131. ReceivePackHandler,
  132. TCPGitServer,
  133. UploadPackHandler,
  134. )
  135. from .server import update_server_info as server_update_server_info
  136. from .sparse_patterns import (
  137. SparseCheckoutConflictError,
  138. apply_included_paths,
  139. determine_included_paths,
  140. )
  141. # Module level tuple definition for status output
  142. GitStatus = namedtuple("GitStatus", "staged unstaged untracked")
  143. @dataclass
  144. class CountObjectsResult:
  145. """Result of counting objects in a repository.
  146. Attributes:
  147. count: Number of loose objects
  148. size: Total size of loose objects in bytes
  149. in_pack: Number of objects in pack files
  150. packs: Number of pack files
  151. size_pack: Total size of pack files in bytes
  152. """
  153. count: int
  154. size: int
  155. in_pack: Optional[int] = None
  156. packs: Optional[int] = None
  157. size_pack: Optional[int] = None
  158. class NoneStream(RawIOBase):
  159. """Fallback if stdout or stderr are unavailable, does nothing."""
  160. def read(self, size=-1) -> None:
  161. return None
  162. def readall(self) -> bytes:
  163. return b""
  164. def readinto(self, b) -> None:
  165. return None
  166. def write(self, b) -> None:
  167. return None
  168. default_bytes_out_stream = getattr(sys.stdout, "buffer", None) or NoneStream()
  169. default_bytes_err_stream = getattr(sys.stderr, "buffer", None) or NoneStream()
  170. DEFAULT_ENCODING = "utf-8"
  171. class Error(Exception):
  172. """Porcelain-based error."""
  173. def __init__(self, msg) -> None:
  174. super().__init__(msg)
  175. class RemoteExists(Error):
  176. """Raised when the remote already exists."""
  177. class TimezoneFormatError(Error):
  178. """Raised when the timezone cannot be determined from a given string."""
  179. class CheckoutError(Error):
  180. """Indicates that a checkout cannot be performed."""
  181. def parse_timezone_format(tz_str):
  182. """Parse given string and attempt to return a timezone offset.
  183. Different formats are considered in the following order:
  184. - Git internal format: <unix timestamp> <timezone offset>
  185. - RFC 2822: e.g. Mon, 20 Nov 1995 19:12:08 -0500
  186. - ISO 8601: e.g. 1995-11-20T19:12:08-0500
  187. Args:
  188. tz_str: datetime string
  189. Returns: Timezone offset as integer
  190. Raises:
  191. TimezoneFormatError: if timezone information cannot be extracted
  192. """
  193. import re
  194. # Git internal format
  195. internal_format_pattern = re.compile("^[0-9]+ [+-][0-9]{,4}$")
  196. if re.match(internal_format_pattern, tz_str):
  197. try:
  198. tz_internal = parse_timezone(tz_str.split(" ")[1].encode(DEFAULT_ENCODING))
  199. return tz_internal[0]
  200. except ValueError:
  201. pass
  202. # RFC 2822
  203. import email.utils
  204. rfc_2822 = email.utils.parsedate_tz(tz_str)
  205. if rfc_2822:
  206. return rfc_2822[9]
  207. # ISO 8601
  208. # Supported offsets:
  209. # sHHMM, sHH:MM, sHH
  210. iso_8601_pattern = re.compile(
  211. "[0-9] ?([+-])([0-9]{2})(?::(?=[0-9]{2}))?([0-9]{2})?$"
  212. )
  213. match = re.search(iso_8601_pattern, tz_str)
  214. total_secs = 0
  215. if match:
  216. sign, hours, minutes = match.groups()
  217. total_secs += int(hours) * 3600
  218. if minutes:
  219. total_secs += int(minutes) * 60
  220. total_secs = -total_secs if sign == "-" else total_secs
  221. return total_secs
  222. # YYYY.MM.DD, MM/DD/YYYY, DD.MM.YYYY contain no timezone information
  223. raise TimezoneFormatError(tz_str)
  224. def get_user_timezones():
  225. """Retrieve local timezone as described in
  226. https://raw.githubusercontent.com/git/git/v2.3.0/Documentation/date-formats.txt
  227. Returns: A tuple containing author timezone, committer timezone.
  228. """
  229. local_timezone = time.localtime().tm_gmtoff
  230. if os.environ.get("GIT_AUTHOR_DATE"):
  231. author_timezone = parse_timezone_format(os.environ["GIT_AUTHOR_DATE"])
  232. else:
  233. author_timezone = local_timezone
  234. if os.environ.get("GIT_COMMITTER_DATE"):
  235. commit_timezone = parse_timezone_format(os.environ["GIT_COMMITTER_DATE"])
  236. else:
  237. commit_timezone = local_timezone
  238. return author_timezone, commit_timezone
  239. def open_repo(path_or_repo: Union[str, os.PathLike, BaseRepo]):
  240. """Open an argument that can be a repository or a path for a repository."""
  241. if isinstance(path_or_repo, BaseRepo):
  242. return path_or_repo
  243. return Repo(path_or_repo)
  244. @contextmanager
  245. def _noop_context_manager(obj):
  246. """Context manager that has the same api as closing but does nothing."""
  247. yield obj
  248. def open_repo_closing(path_or_repo: Union[str, os.PathLike, BaseRepo]):
  249. """Open an argument that can be a repository or a path for a repository.
  250. returns a context manager that will close the repo on exit if the argument
  251. is a path, else does nothing if the argument is a repo.
  252. """
  253. if isinstance(path_or_repo, BaseRepo):
  254. return _noop_context_manager(path_or_repo)
  255. return closing(Repo(path_or_repo))
  256. def path_to_tree_path(repopath, path, tree_encoding=DEFAULT_ENCODING):
  257. """Convert a path to a path usable in an index, e.g. bytes and relative to
  258. the repository root.
  259. Args:
  260. repopath: Repository path, absolute or relative to the cwd
  261. path: A path, absolute or relative to the cwd
  262. Returns: A path formatted for use in e.g. an index
  263. """
  264. # Resolve might returns a relative path on Windows
  265. # https://bugs.python.org/issue38671
  266. if sys.platform == "win32":
  267. path = os.path.abspath(path)
  268. path = Path(path)
  269. resolved_path = path.resolve()
  270. # Resolve and abspath seems to behave differently regarding symlinks,
  271. # as we are doing abspath on the file path, we need to do the same on
  272. # the repo path or they might not match
  273. if sys.platform == "win32":
  274. repopath = os.path.abspath(repopath)
  275. repopath = Path(repopath).resolve()
  276. try:
  277. relpath = resolved_path.relative_to(repopath)
  278. except ValueError:
  279. # If path is a symlink that points to a file outside the repo, we
  280. # want the relpath for the link itself, not the resolved target
  281. if path.is_symlink():
  282. parent = path.parent.resolve()
  283. relpath = (parent / path.name).relative_to(repopath)
  284. else:
  285. raise
  286. if sys.platform == "win32":
  287. return str(relpath).replace(os.path.sep, "/").encode(tree_encoding)
  288. else:
  289. return bytes(relpath)
  290. class DivergedBranches(Error):
  291. """Branches have diverged and fast-forward is not possible."""
  292. def __init__(self, current_sha, new_sha) -> None:
  293. self.current_sha = current_sha
  294. self.new_sha = new_sha
  295. def check_diverged(repo, current_sha, new_sha) -> None:
  296. """Check if updating to a sha can be done with fast forwarding.
  297. Args:
  298. repo: Repository object
  299. current_sha: Current head sha
  300. new_sha: New head sha
  301. """
  302. try:
  303. can = can_fast_forward(repo, current_sha, new_sha)
  304. except KeyError:
  305. can = False
  306. if not can:
  307. raise DivergedBranches(current_sha, new_sha)
  308. def archive(
  309. repo,
  310. committish=None,
  311. outstream=default_bytes_out_stream,
  312. errstream=default_bytes_err_stream,
  313. ) -> None:
  314. """Create an archive.
  315. Args:
  316. repo: Path of repository for which to generate an archive.
  317. committish: Commit SHA1 or ref to use
  318. outstream: Output stream (defaults to stdout)
  319. errstream: Error stream (defaults to stderr)
  320. """
  321. if committish is None:
  322. committish = "HEAD"
  323. with open_repo_closing(repo) as repo_obj:
  324. c = parse_commit(repo_obj, committish)
  325. for chunk in tar_stream(
  326. repo_obj.object_store, repo_obj.object_store[c.tree], c.commit_time
  327. ):
  328. outstream.write(chunk)
  329. def update_server_info(repo=".") -> None:
  330. """Update server info files for a repository.
  331. Args:
  332. repo: path to the repository
  333. """
  334. with open_repo_closing(repo) as r:
  335. server_update_server_info(r)
  336. def symbolic_ref(repo, ref_name, force=False) -> None:
  337. """Set git symbolic ref into HEAD.
  338. Args:
  339. repo: path to the repository
  340. ref_name: short name of the new ref
  341. force: force settings without checking if it exists in refs/heads
  342. """
  343. with open_repo_closing(repo) as repo_obj:
  344. ref_path = _make_branch_ref(ref_name)
  345. if not force and ref_path not in repo_obj.refs.keys():
  346. raise Error(f"fatal: ref `{ref_name}` is not a ref")
  347. repo_obj.refs.set_symbolic_ref(b"HEAD", ref_path)
  348. def pack_refs(repo, all=False) -> None:
  349. with open_repo_closing(repo) as repo_obj:
  350. repo_obj.refs.pack_refs(all=all)
  351. def commit(
  352. repo=".",
  353. message=None,
  354. author=None,
  355. author_timezone=None,
  356. committer=None,
  357. commit_timezone=None,
  358. encoding=None,
  359. no_verify=False,
  360. signoff=False,
  361. ):
  362. """Create a new commit.
  363. Args:
  364. repo: Path to repository
  365. message: Optional commit message
  366. author: Optional author name and email
  367. author_timezone: Author timestamp timezone
  368. committer: Optional committer name and email
  369. commit_timezone: Commit timestamp timezone
  370. no_verify: Skip pre-commit and commit-msg hooks
  371. signoff: GPG Sign the commit (bool, defaults to False,
  372. pass True to use default GPG key,
  373. pass a str containing Key ID to use a specific GPG key)
  374. Returns: SHA1 of the new commit
  375. """
  376. # FIXME: Support --all argument
  377. if getattr(message, "encode", None):
  378. message = message.encode(encoding or DEFAULT_ENCODING)
  379. if getattr(author, "encode", None):
  380. author = author.encode(encoding or DEFAULT_ENCODING)
  381. if getattr(committer, "encode", None):
  382. committer = committer.encode(encoding or DEFAULT_ENCODING)
  383. local_timezone = get_user_timezones()
  384. if author_timezone is None:
  385. author_timezone = local_timezone[0]
  386. if commit_timezone is None:
  387. commit_timezone = local_timezone[1]
  388. with open_repo_closing(repo) as r:
  389. return r.do_commit(
  390. message=message,
  391. author=author,
  392. author_timezone=author_timezone,
  393. committer=committer,
  394. commit_timezone=commit_timezone,
  395. encoding=encoding,
  396. no_verify=no_verify,
  397. sign=signoff if isinstance(signoff, (str, bool)) else None,
  398. )
  399. def commit_tree(repo, tree, message=None, author=None, committer=None):
  400. """Create a new commit object.
  401. Args:
  402. repo: Path to repository
  403. tree: An existing tree object
  404. author: Optional author name and email
  405. committer: Optional committer name and email
  406. """
  407. with open_repo_closing(repo) as r:
  408. return r.do_commit(
  409. message=message, tree=tree, committer=committer, author=author
  410. )
  411. def init(
  412. path: Union[str, os.PathLike] = ".", *, bare=False, symlinks: Optional[bool] = None
  413. ):
  414. """Create a new git repository.
  415. Args:
  416. path: Path to repository.
  417. bare: Whether to create a bare repository.
  418. symlinks: Whether to create actual symlinks (defaults to autodetect)
  419. Returns: A Repo instance
  420. """
  421. if not os.path.exists(path):
  422. os.mkdir(path)
  423. if bare:
  424. return Repo.init_bare(path)
  425. else:
  426. return Repo.init(path, symlinks=symlinks)
  427. def clone(
  428. source,
  429. target: Optional[Union[str, os.PathLike]] = None,
  430. bare=False,
  431. checkout=None,
  432. errstream=default_bytes_err_stream,
  433. outstream=None,
  434. origin: Optional[str] = "origin",
  435. depth: Optional[int] = None,
  436. branch: Optional[Union[str, bytes]] = None,
  437. config: Optional[Config] = None,
  438. filter_spec=None,
  439. protocol_version: Optional[int] = None,
  440. **kwargs,
  441. ):
  442. """Clone a local or remote git repository.
  443. Args:
  444. source: Path or URL for source repository
  445. target: Path to target repository (optional)
  446. bare: Whether or not to create a bare repository
  447. checkout: Whether or not to check-out HEAD after cloning
  448. errstream: Optional stream to write progress to
  449. outstream: Optional stream to write progress to (deprecated)
  450. origin: Name of remote from the repository used to clone
  451. depth: Depth to fetch at
  452. branch: Optional branch or tag to be used as HEAD in the new repository
  453. instead of the cloned repository's HEAD.
  454. config: Configuration to use
  455. filter_spec: A git-rev-list-style object filter spec, as an ASCII string.
  456. Only used if the server supports the Git protocol-v2 'filter'
  457. feature, and ignored otherwise.
  458. protocol_version: desired Git protocol version. By default the highest
  459. mutually supported protocol version will be used.
  460. Keyword Args:
  461. refspecs: refspecs to fetch. Can be a bytestring, a string, or a list of
  462. bytestring/string.
  463. Returns: The new repository
  464. """
  465. if outstream is not None:
  466. import warnings
  467. warnings.warn(
  468. "outstream= has been deprecated in favour of errstream=.",
  469. DeprecationWarning,
  470. stacklevel=3,
  471. )
  472. # TODO(jelmer): Capture logging output and stream to errstream
  473. if config is None:
  474. config = StackedConfig.default()
  475. if checkout is None:
  476. checkout = not bare
  477. if checkout and bare:
  478. raise Error("checkout and bare are incompatible")
  479. if target is None:
  480. target = source.split("/")[-1]
  481. if isinstance(branch, str):
  482. branch = branch.encode(DEFAULT_ENCODING)
  483. mkdir = not os.path.exists(target)
  484. (client, path) = get_transport_and_path(source, config=config, **kwargs)
  485. if filter_spec:
  486. filter_spec = filter_spec.encode("ascii")
  487. return client.clone(
  488. path,
  489. target,
  490. mkdir=mkdir,
  491. bare=bare,
  492. origin=origin,
  493. checkout=checkout,
  494. branch=branch,
  495. progress=errstream.write,
  496. depth=depth,
  497. filter_spec=filter_spec,
  498. protocol_version=protocol_version,
  499. )
  500. def add(repo: Union[str, os.PathLike, BaseRepo] = ".", paths=None):
  501. """Add files to the staging area.
  502. Args:
  503. repo: Repository for the files
  504. paths: Paths to add. If None, stages all untracked and modified files from the
  505. current working directory (mimicking 'git add .' behavior).
  506. Returns: Tuple with set of added files and ignored files
  507. If the repository contains ignored directories, the returned set will
  508. contain the path to an ignored directory (with trailing slash). Individual
  509. files within ignored directories will not be returned.
  510. Note: When paths=None, this function adds all untracked and modified files
  511. from the entire repository, mimicking 'git add -A' behavior.
  512. """
  513. ignored = set()
  514. with open_repo_closing(repo) as r:
  515. repo_path = Path(r.path).resolve()
  516. ignore_manager = IgnoreFilterManager.from_repo(r)
  517. # Get unstaged changes once for the entire operation
  518. index = r.open_index()
  519. normalizer = r.get_blob_normalizer()
  520. filter_callback = normalizer.checkin_normalize
  521. all_unstaged_paths = list(get_unstaged_changes(index, r.path, filter_callback))
  522. if not paths:
  523. # When no paths specified, add all untracked and modified files from repo root
  524. paths = [str(repo_path)]
  525. relpaths = []
  526. if not isinstance(paths, list):
  527. paths = [paths]
  528. for p in paths:
  529. path = Path(p)
  530. if not path.is_absolute():
  531. # Make relative paths relative to the repo directory
  532. path = repo_path / path
  533. # Don't resolve symlinks completely - only resolve the parent directory
  534. # to avoid issues when symlinks point outside the repository
  535. if path.is_symlink():
  536. # For symlinks, resolve only the parent directory
  537. parent_resolved = path.parent.resolve()
  538. resolved_path = parent_resolved / path.name
  539. else:
  540. # For regular files/dirs, resolve normally
  541. resolved_path = path.resolve()
  542. try:
  543. relpath = str(resolved_path.relative_to(repo_path)).replace(os.sep, "/")
  544. except ValueError:
  545. # Path is not within the repository
  546. raise ValueError(f"Path {p} is not within repository {repo_path}")
  547. # Handle directories by scanning their contents
  548. if resolved_path.is_dir():
  549. # Check if the directory itself is ignored
  550. dir_relpath = posixpath.join(relpath, "") if relpath != "." else ""
  551. if dir_relpath and ignore_manager.is_ignored(dir_relpath):
  552. ignored.add(dir_relpath)
  553. continue
  554. # When adding a directory, add all untracked files within it
  555. current_untracked = list(
  556. get_untracked_paths(
  557. str(resolved_path),
  558. str(repo_path),
  559. index,
  560. )
  561. )
  562. for untracked_path in current_untracked:
  563. # If we're scanning a subdirectory, adjust the path
  564. if relpath != ".":
  565. untracked_path = posixpath.join(relpath, untracked_path)
  566. if not ignore_manager.is_ignored(untracked_path):
  567. relpaths.append(untracked_path)
  568. else:
  569. ignored.add(untracked_path)
  570. # Also add unstaged (modified) files within this directory
  571. for unstaged_path in all_unstaged_paths:
  572. if isinstance(unstaged_path, bytes):
  573. unstaged_path_str = unstaged_path.decode("utf-8")
  574. else:
  575. unstaged_path_str = unstaged_path
  576. # Check if this unstaged file is within the directory we're processing
  577. unstaged_full_path = repo_path / unstaged_path_str
  578. try:
  579. unstaged_full_path.relative_to(resolved_path)
  580. # File is within this directory, add it
  581. if not ignore_manager.is_ignored(unstaged_path_str):
  582. relpaths.append(unstaged_path_str)
  583. else:
  584. ignored.add(unstaged_path_str)
  585. except ValueError:
  586. # File is not within this directory, skip it
  587. continue
  588. continue
  589. # FIXME: Support patterns
  590. if ignore_manager.is_ignored(relpath):
  591. ignored.add(relpath)
  592. continue
  593. relpaths.append(relpath)
  594. r.stage(relpaths)
  595. return (relpaths, ignored)
  596. def _is_subdir(subdir, parentdir):
  597. """Check whether subdir is parentdir or a subdir of parentdir.
  598. If parentdir or subdir is a relative path, it will be disamgibuated
  599. relative to the pwd.
  600. """
  601. parentdir_abs = os.path.realpath(parentdir) + os.path.sep
  602. subdir_abs = os.path.realpath(subdir) + os.path.sep
  603. return subdir_abs.startswith(parentdir_abs)
  604. # TODO: option to remove ignored files also, in line with `git clean -fdx`
  605. def clean(repo=".", target_dir=None) -> None:
  606. """Remove any untracked files from the target directory recursively.
  607. Equivalent to running ``git clean -fd`` in target_dir.
  608. Args:
  609. repo: Repository where the files may be tracked
  610. target_dir: Directory to clean - current directory if None
  611. """
  612. if target_dir is None:
  613. target_dir = os.getcwd()
  614. with open_repo_closing(repo) as r:
  615. if not _is_subdir(target_dir, r.path):
  616. raise Error("target_dir must be in the repo's working dir")
  617. config = r.get_config_stack()
  618. config.get_boolean((b"clean",), b"requireForce", True)
  619. # TODO(jelmer): if require_force is set, then make sure that -f, -i or
  620. # -n is specified.
  621. index = r.open_index()
  622. ignore_manager = IgnoreFilterManager.from_repo(r)
  623. paths_in_wd = _walk_working_dir_paths(target_dir, r.path)
  624. # Reverse file visit order, so that files and subdirectories are
  625. # removed before containing directory
  626. for ap, is_dir in reversed(list(paths_in_wd)):
  627. if is_dir:
  628. # All subdirectories and files have been removed if untracked,
  629. # so dir contains no tracked files iff it is empty.
  630. is_empty = len(os.listdir(ap)) == 0
  631. if is_empty:
  632. os.rmdir(ap)
  633. else:
  634. ip = path_to_tree_path(r.path, ap)
  635. is_tracked = ip in index
  636. rp = os.path.relpath(ap, r.path)
  637. is_ignored = ignore_manager.is_ignored(rp)
  638. if not is_tracked and not is_ignored:
  639. os.remove(ap)
  640. def remove(repo=".", paths=None, cached=False) -> None:
  641. """Remove files from the staging area.
  642. Args:
  643. repo: Repository for the files
  644. paths: Paths to remove
  645. """
  646. with open_repo_closing(repo) as r:
  647. index = r.open_index()
  648. for p in paths:
  649. full_path = os.fsencode(os.path.abspath(p))
  650. tree_path = path_to_tree_path(r.path, p)
  651. try:
  652. index_sha = index[tree_path].sha
  653. except KeyError as exc:
  654. raise Error(f"{p} did not match any files") from exc
  655. if not cached:
  656. try:
  657. st = os.lstat(full_path)
  658. except OSError:
  659. pass
  660. else:
  661. try:
  662. blob = blob_from_path_and_stat(full_path, st)
  663. except OSError:
  664. pass
  665. else:
  666. try:
  667. committed_sha = tree_lookup_path(
  668. r.__getitem__, r[r.head()].tree, tree_path
  669. )[1]
  670. except KeyError:
  671. committed_sha = None
  672. if blob.id != index_sha and index_sha != committed_sha:
  673. raise Error(
  674. "file has staged content differing "
  675. f"from both the file and head: {p}"
  676. )
  677. if index_sha != committed_sha:
  678. raise Error(f"file has staged changes: {p}")
  679. os.remove(full_path)
  680. del index[tree_path]
  681. index.write()
  682. rm = remove
  683. def commit_decode(commit, contents, default_encoding=DEFAULT_ENCODING):
  684. if commit.encoding:
  685. encoding = commit.encoding.decode("ascii")
  686. else:
  687. encoding = default_encoding
  688. return contents.decode(encoding, "replace")
  689. def commit_encode(commit, contents, default_encoding=DEFAULT_ENCODING):
  690. if commit.encoding:
  691. encoding = commit.encoding.decode("ascii")
  692. else:
  693. encoding = default_encoding
  694. return contents.encode(encoding)
  695. def print_commit(commit, decode, outstream=sys.stdout) -> None:
  696. """Write a human-readable commit log entry.
  697. Args:
  698. commit: A `Commit` object
  699. outstream: A stream file to write to
  700. """
  701. outstream.write("-" * 50 + "\n")
  702. outstream.write("commit: " + commit.id.decode("ascii") + "\n")
  703. if len(commit.parents) > 1:
  704. outstream.write(
  705. "merge: "
  706. + "...".join([c.decode("ascii") for c in commit.parents[1:]])
  707. + "\n"
  708. )
  709. outstream.write("Author: " + decode(commit.author) + "\n")
  710. if commit.author != commit.committer:
  711. outstream.write("Committer: " + decode(commit.committer) + "\n")
  712. time_tuple = time.gmtime(commit.author_time + commit.author_timezone)
  713. time_str = time.strftime("%a %b %d %Y %H:%M:%S", time_tuple)
  714. timezone_str = format_timezone(commit.author_timezone).decode("ascii")
  715. outstream.write("Date: " + time_str + " " + timezone_str + "\n")
  716. if commit.message:
  717. outstream.write("\n")
  718. outstream.write(decode(commit.message) + "\n")
  719. outstream.write("\n")
  720. def print_tag(tag, decode, outstream=sys.stdout) -> None:
  721. """Write a human-readable tag.
  722. Args:
  723. tag: A `Tag` object
  724. decode: Function for decoding bytes to unicode string
  725. outstream: A stream to write to
  726. """
  727. outstream.write("Tagger: " + decode(tag.tagger) + "\n")
  728. time_tuple = time.gmtime(tag.tag_time + tag.tag_timezone)
  729. time_str = time.strftime("%a %b %d %Y %H:%M:%S", time_tuple)
  730. timezone_str = format_timezone(tag.tag_timezone).decode("ascii")
  731. outstream.write("Date: " + time_str + " " + timezone_str + "\n")
  732. outstream.write("\n")
  733. outstream.write(decode(tag.message))
  734. outstream.write("\n")
  735. def show_blob(repo, blob, decode, outstream=sys.stdout) -> None:
  736. """Write a blob to a stream.
  737. Args:
  738. repo: A `Repo` object
  739. blob: A `Blob` object
  740. decode: Function for decoding bytes to unicode string
  741. outstream: A stream file to write to
  742. """
  743. outstream.write(decode(blob.data))
  744. def show_commit(repo, commit, decode, outstream=sys.stdout) -> None:
  745. """Show a commit to a stream.
  746. Args:
  747. repo: A `Repo` object
  748. commit: A `Commit` object
  749. decode: Function for decoding bytes to unicode string
  750. outstream: Stream to write to
  751. """
  752. print_commit(commit, decode=decode, outstream=outstream)
  753. if commit.parents:
  754. parent_commit = repo[commit.parents[0]]
  755. base_tree = parent_commit.tree
  756. else:
  757. base_tree = None
  758. diffstream = BytesIO()
  759. write_tree_diff(diffstream, repo.object_store, base_tree, commit.tree)
  760. diffstream.seek(0)
  761. outstream.write(commit_decode(commit, diffstream.getvalue()))
  762. def show_tree(repo, tree, decode, outstream=sys.stdout) -> None:
  763. """Print a tree to a stream.
  764. Args:
  765. repo: A `Repo` object
  766. tree: A `Tree` object
  767. decode: Function for decoding bytes to unicode string
  768. outstream: Stream to write to
  769. """
  770. for n in tree:
  771. outstream.write(decode(n) + "\n")
  772. def show_tag(repo, tag, decode, outstream=sys.stdout) -> None:
  773. """Print a tag to a stream.
  774. Args:
  775. repo: A `Repo` object
  776. tag: A `Tag` object
  777. decode: Function for decoding bytes to unicode string
  778. outstream: Stream to write to
  779. """
  780. print_tag(tag, decode, outstream)
  781. show_object(repo, repo[tag.object[1]], decode, outstream)
  782. def show_object(repo, obj, decode, outstream):
  783. return {
  784. b"tree": show_tree,
  785. b"blob": show_blob,
  786. b"commit": show_commit,
  787. b"tag": show_tag,
  788. }[obj.type_name](repo, obj, decode, outstream)
  789. def print_name_status(changes):
  790. """Print a simple status summary, listing changed files."""
  791. for change in changes:
  792. if not change:
  793. continue
  794. if isinstance(change, list):
  795. change = change[0]
  796. if change.type == CHANGE_ADD:
  797. path1 = change.new.path
  798. path2 = ""
  799. kind = "A"
  800. elif change.type == CHANGE_DELETE:
  801. path1 = change.old.path
  802. path2 = ""
  803. kind = "D"
  804. elif change.type == CHANGE_MODIFY:
  805. path1 = change.new.path
  806. path2 = ""
  807. kind = "M"
  808. elif change.type in RENAME_CHANGE_TYPES:
  809. path1 = change.old.path
  810. path2 = change.new.path
  811. if change.type == CHANGE_RENAME:
  812. kind = "R"
  813. elif change.type == CHANGE_COPY:
  814. kind = "C"
  815. yield "%-8s%-20s%-20s" % (kind, path1, path2) # noqa: UP031
  816. def log(
  817. repo=".",
  818. paths=None,
  819. outstream=sys.stdout,
  820. max_entries=None,
  821. reverse=False,
  822. name_status=False,
  823. ) -> None:
  824. """Write commit logs.
  825. Args:
  826. repo: Path to repository
  827. paths: Optional set of specific paths to print entries for
  828. outstream: Stream to write log output to
  829. reverse: Reverse order in which entries are printed
  830. name_status: Print name status
  831. max_entries: Optional maximum number of entries to display
  832. """
  833. with open_repo_closing(repo) as r:
  834. try:
  835. include = [r.head()]
  836. except KeyError:
  837. include = []
  838. walker = r.get_walker(
  839. include=include, max_entries=max_entries, paths=paths, reverse=reverse
  840. )
  841. for entry in walker:
  842. def decode(x):
  843. return commit_decode(entry.commit, x)
  844. print_commit(entry.commit, decode, outstream)
  845. if name_status:
  846. outstream.writelines(
  847. [line + "\n" for line in print_name_status(entry.changes())]
  848. )
  849. # TODO(jelmer): better default for encoding?
  850. def show(
  851. repo=".",
  852. objects=None,
  853. outstream=sys.stdout,
  854. default_encoding=DEFAULT_ENCODING,
  855. ) -> None:
  856. """Print the changes in a commit.
  857. Args:
  858. repo: Path to repository
  859. objects: Objects to show (defaults to [HEAD])
  860. outstream: Stream to write to
  861. default_encoding: Default encoding to use if none is set in the
  862. commit
  863. """
  864. if objects is None:
  865. objects = ["HEAD"]
  866. if not isinstance(objects, list):
  867. objects = [objects]
  868. with open_repo_closing(repo) as r:
  869. for objectish in objects:
  870. o = parse_object(r, objectish)
  871. if isinstance(o, Commit):
  872. def decode(x):
  873. return commit_decode(o, x, default_encoding)
  874. else:
  875. def decode(x):
  876. return x.decode(default_encoding)
  877. show_object(r, o, decode, outstream)
  878. def diff_tree(repo, old_tree, new_tree, outstream=default_bytes_out_stream) -> None:
  879. """Compares the content and mode of blobs found via two tree objects.
  880. Args:
  881. repo: Path to repository
  882. old_tree: Id of old tree
  883. new_tree: Id of new tree
  884. outstream: Stream to write to
  885. """
  886. with open_repo_closing(repo) as r:
  887. write_tree_diff(outstream, r.object_store, old_tree, new_tree)
  888. def rev_list(repo, commits, outstream=sys.stdout) -> None:
  889. """Lists commit objects in reverse chronological order.
  890. Args:
  891. repo: Path to repository
  892. commits: Commits over which to iterate
  893. outstream: Stream to write to
  894. """
  895. with open_repo_closing(repo) as r:
  896. for entry in r.get_walker(include=[r[c].id for c in commits]):
  897. outstream.write(entry.commit.id + b"\n")
  898. def _canonical_part(url: str) -> str:
  899. name = url.rsplit("/", 1)[-1]
  900. if name.endswith(".git"):
  901. name = name[:-4]
  902. return name
  903. def submodule_add(repo, url, path=None, name=None) -> None:
  904. """Add a new submodule.
  905. Args:
  906. repo: Path to repository
  907. url: URL of repository to add as submodule
  908. path: Path where submodule should live
  909. """
  910. with open_repo_closing(repo) as r:
  911. if path is None:
  912. path = os.path.relpath(_canonical_part(url), r.path)
  913. if name is None:
  914. name = path
  915. # TODO(jelmer): Move this logic to dulwich.submodule
  916. gitmodules_path = os.path.join(r.path, ".gitmodules")
  917. try:
  918. config = ConfigFile.from_path(gitmodules_path)
  919. except FileNotFoundError:
  920. config = ConfigFile()
  921. config.path = gitmodules_path
  922. config.set(("submodule", name), "url", url)
  923. config.set(("submodule", name), "path", path)
  924. config.write_to_path()
  925. def submodule_init(repo) -> None:
  926. """Initialize submodules.
  927. Args:
  928. repo: Path to repository
  929. """
  930. with open_repo_closing(repo) as r:
  931. config = r.get_config()
  932. gitmodules_path = os.path.join(r.path, ".gitmodules")
  933. for path, url, name in read_submodules(gitmodules_path):
  934. config.set((b"submodule", name), b"active", True)
  935. config.set((b"submodule", name), b"url", url)
  936. config.write_to_path()
  937. def submodule_list(repo):
  938. """List submodules.
  939. Args:
  940. repo: Path to repository
  941. """
  942. from .submodule import iter_cached_submodules
  943. with open_repo_closing(repo) as r:
  944. for path, sha in iter_cached_submodules(r.object_store, r[r.head()].tree):
  945. yield path, sha.decode(DEFAULT_ENCODING)
  946. def tag_create(
  947. repo,
  948. tag: Union[str, bytes],
  949. author: Optional[Union[str, bytes]] = None,
  950. message: Optional[Union[str, bytes]] = None,
  951. annotated=False,
  952. objectish: Union[str, bytes] = "HEAD",
  953. tag_time=None,
  954. tag_timezone=None,
  955. sign: bool = False,
  956. encoding: str = DEFAULT_ENCODING,
  957. ) -> None:
  958. """Creates a tag in git via dulwich calls.
  959. Args:
  960. repo: Path to repository
  961. tag: tag string
  962. author: tag author (optional, if annotated is set)
  963. message: tag message (optional)
  964. annotated: whether to create an annotated tag
  965. objectish: object the tag should point at, defaults to HEAD
  966. tag_time: Optional time for annotated tag
  967. tag_timezone: Optional timezone for annotated tag
  968. sign: GPG Sign the tag (bool, defaults to False,
  969. pass True to use default GPG key,
  970. pass a str containing Key ID to use a specific GPG key)
  971. """
  972. with open_repo_closing(repo) as r:
  973. object = parse_object(r, objectish)
  974. if isinstance(tag, str):
  975. tag = tag.encode(encoding)
  976. if annotated:
  977. # Create the tag object
  978. tag_obj = Tag()
  979. if author is None:
  980. author = get_user_identity(r.get_config_stack())
  981. elif isinstance(author, str):
  982. author = author.encode(encoding)
  983. else:
  984. assert isinstance(author, bytes)
  985. tag_obj.tagger = author
  986. if isinstance(message, str):
  987. message = message.encode(encoding)
  988. elif isinstance(message, bytes):
  989. pass
  990. else:
  991. message = b""
  992. tag_obj.message = message + "\n".encode(encoding)
  993. tag_obj.name = tag
  994. tag_obj.object = (type(object), object.id)
  995. if tag_time is None:
  996. tag_time = int(time.time())
  997. tag_obj.tag_time = tag_time
  998. if tag_timezone is None:
  999. tag_timezone = get_user_timezones()[1]
  1000. elif isinstance(tag_timezone, str):
  1001. tag_timezone = parse_timezone(tag_timezone.encode())
  1002. tag_obj.tag_timezone = tag_timezone
  1003. if sign:
  1004. tag_obj.sign(sign if isinstance(sign, str) else None)
  1005. r.object_store.add_object(tag_obj)
  1006. tag_id = tag_obj.id
  1007. else:
  1008. tag_id = object.id
  1009. r.refs[_make_tag_ref(tag)] = tag_id
  1010. def tag_list(repo, outstream=sys.stdout):
  1011. """List all tags.
  1012. Args:
  1013. repo: Path to repository
  1014. outstream: Stream to write tags to
  1015. """
  1016. with open_repo_closing(repo) as r:
  1017. tags = sorted(r.refs.as_dict(b"refs/tags"))
  1018. return tags
  1019. def tag_delete(repo, name) -> None:
  1020. """Remove a tag.
  1021. Args:
  1022. repo: Path to repository
  1023. name: Name of tag to remove
  1024. """
  1025. with open_repo_closing(repo) as r:
  1026. if isinstance(name, bytes):
  1027. names = [name]
  1028. elif isinstance(name, list):
  1029. names = name
  1030. else:
  1031. raise Error(f"Unexpected tag name type {name!r}")
  1032. for name in names:
  1033. del r.refs[_make_tag_ref(name)]
  1034. def _make_notes_ref(name: bytes) -> bytes:
  1035. """Make a notes ref name."""
  1036. if name.startswith(b"refs/notes/"):
  1037. return name
  1038. return LOCAL_NOTES_PREFIX + name
  1039. def notes_add(
  1040. repo, object_sha, note, ref=b"commits", author=None, committer=None, message=None
  1041. ):
  1042. """Add or update a note for an object.
  1043. Args:
  1044. repo: Path to repository
  1045. object_sha: SHA of the object to annotate
  1046. note: Note content
  1047. ref: Notes ref to use (defaults to "commits" for refs/notes/commits)
  1048. author: Author identity (defaults to committer)
  1049. committer: Committer identity (defaults to config)
  1050. message: Commit message for the notes update
  1051. Returns:
  1052. SHA of the new notes commit
  1053. """
  1054. with open_repo_closing(repo) as r:
  1055. # Parse the object to get its SHA
  1056. obj = parse_object(r, object_sha)
  1057. object_sha = obj.id
  1058. if isinstance(note, str):
  1059. note = note.encode(DEFAULT_ENCODING)
  1060. if isinstance(ref, str):
  1061. ref = ref.encode(DEFAULT_ENCODING)
  1062. notes_ref = _make_notes_ref(ref)
  1063. config = r.get_config_stack()
  1064. return r.notes.set_note(
  1065. object_sha,
  1066. note,
  1067. notes_ref,
  1068. author=author,
  1069. committer=committer,
  1070. message=message,
  1071. config=config,
  1072. )
  1073. def notes_remove(
  1074. repo, object_sha, ref=b"commits", author=None, committer=None, message=None
  1075. ):
  1076. """Remove a note for an object.
  1077. Args:
  1078. repo: Path to repository
  1079. object_sha: SHA of the object to remove notes from
  1080. ref: Notes ref to use (defaults to "commits" for refs/notes/commits)
  1081. author: Author identity (defaults to committer)
  1082. committer: Committer identity (defaults to config)
  1083. message: Commit message for the notes removal
  1084. Returns:
  1085. SHA of the new notes commit, or None if no note existed
  1086. """
  1087. with open_repo_closing(repo) as r:
  1088. # Parse the object to get its SHA
  1089. obj = parse_object(r, object_sha)
  1090. object_sha = obj.id
  1091. if isinstance(ref, str):
  1092. ref = ref.encode(DEFAULT_ENCODING)
  1093. notes_ref = _make_notes_ref(ref)
  1094. config = r.get_config_stack()
  1095. return r.notes.remove_note(
  1096. object_sha,
  1097. notes_ref,
  1098. author=author,
  1099. committer=committer,
  1100. message=message,
  1101. config=config,
  1102. )
  1103. def notes_show(repo, object_sha, ref=b"commits"):
  1104. """Show the note for an object.
  1105. Args:
  1106. repo: Path to repository
  1107. object_sha: SHA of the object
  1108. ref: Notes ref to use (defaults to "commits" for refs/notes/commits)
  1109. Returns:
  1110. Note content as bytes, or None if no note exists
  1111. """
  1112. with open_repo_closing(repo) as r:
  1113. # Parse the object to get its SHA
  1114. obj = parse_object(r, object_sha)
  1115. object_sha = obj.id
  1116. if isinstance(ref, str):
  1117. ref = ref.encode(DEFAULT_ENCODING)
  1118. notes_ref = _make_notes_ref(ref)
  1119. config = r.get_config_stack()
  1120. return r.notes.get_note(object_sha, notes_ref, config=config)
  1121. def notes_list(repo, ref=b"commits"):
  1122. """List all notes in a notes ref.
  1123. Args:
  1124. repo: Path to repository
  1125. ref: Notes ref to use (defaults to "commits" for refs/notes/commits)
  1126. Returns:
  1127. List of tuples of (object_sha, note_content)
  1128. """
  1129. with open_repo_closing(repo) as r:
  1130. if isinstance(ref, str):
  1131. ref = ref.encode(DEFAULT_ENCODING)
  1132. notes_ref = _make_notes_ref(ref)
  1133. config = r.get_config_stack()
  1134. return r.notes.list_notes(notes_ref, config=config)
  1135. def reset(repo, mode, treeish="HEAD") -> None:
  1136. """Reset current HEAD to the specified state.
  1137. Args:
  1138. repo: Path to repository
  1139. mode: Mode ("hard", "soft", "mixed")
  1140. treeish: Treeish to reset to
  1141. """
  1142. if mode != "hard":
  1143. raise Error("hard is the only mode currently supported")
  1144. with open_repo_closing(repo) as r:
  1145. tree = parse_tree(r, treeish)
  1146. # Get current HEAD tree for comparison
  1147. try:
  1148. current_head = r.refs[b"HEAD"]
  1149. current_tree = r[current_head].tree
  1150. except KeyError:
  1151. current_tree = None
  1152. # Get configuration for working directory update
  1153. config = r.get_config()
  1154. honor_filemode = config.get_boolean(b"core", b"filemode", os.name != "nt")
  1155. # Import validation functions
  1156. from .index import validate_path_element_default, validate_path_element_ntfs
  1157. if config.get_boolean(b"core", b"core.protectNTFS", os.name == "nt"):
  1158. validate_path_element = validate_path_element_ntfs
  1159. else:
  1160. validate_path_element = validate_path_element_default
  1161. if config.get_boolean(b"core", b"symlinks", True):
  1162. # Import symlink function
  1163. from .index import symlink
  1164. symlink_fn = symlink
  1165. else:
  1166. def symlink_fn( # type: ignore
  1167. source, target, target_is_directory=False, *, dir_fd=None
  1168. ) -> None:
  1169. mode = "w" + ("b" if isinstance(source, bytes) else "")
  1170. with open(target, mode) as f:
  1171. f.write(source)
  1172. # Update working tree and index
  1173. update_working_tree(
  1174. r,
  1175. current_tree,
  1176. tree.id,
  1177. honor_filemode=honor_filemode,
  1178. validate_path_element=validate_path_element,
  1179. symlink_fn=symlink_fn,
  1180. force_remove_untracked=True,
  1181. )
  1182. def get_remote_repo(
  1183. repo: Repo, remote_location: Optional[Union[str, bytes]] = None
  1184. ) -> tuple[Optional[str], str]:
  1185. config = repo.get_config()
  1186. if remote_location is None:
  1187. remote_location = get_branch_remote(repo)
  1188. if isinstance(remote_location, str):
  1189. encoded_location = remote_location.encode()
  1190. else:
  1191. encoded_location = remote_location
  1192. section = (b"remote", encoded_location)
  1193. remote_name: Optional[str] = None
  1194. if config.has_section(section):
  1195. remote_name = encoded_location.decode()
  1196. encoded_location = config.get(section, "url")
  1197. else:
  1198. remote_name = None
  1199. return (remote_name, encoded_location.decode())
  1200. def push(
  1201. repo,
  1202. remote_location=None,
  1203. refspecs=None,
  1204. outstream=default_bytes_out_stream,
  1205. errstream=default_bytes_err_stream,
  1206. force=False,
  1207. **kwargs,
  1208. ) -> None:
  1209. """Remote push with dulwich via dulwich.client.
  1210. Args:
  1211. repo: Path to repository
  1212. remote_location: Location of the remote
  1213. refspecs: Refs to push to remote
  1214. outstream: A stream file to write output
  1215. errstream: A stream file to write errors
  1216. force: Force overwriting refs
  1217. """
  1218. # Open the repo
  1219. with open_repo_closing(repo) as r:
  1220. if refspecs is None:
  1221. refspecs = [active_branch(r)]
  1222. (remote_name, remote_location) = get_remote_repo(r, remote_location)
  1223. # Get the client and path
  1224. client, path = get_transport_and_path(
  1225. remote_location, config=r.get_config_stack(), **kwargs
  1226. )
  1227. selected_refs = []
  1228. remote_changed_refs = {}
  1229. def update_refs(refs):
  1230. selected_refs.extend(parse_reftuples(r.refs, refs, refspecs, force=force))
  1231. new_refs = {}
  1232. # TODO: Handle selected_refs == {None: None}
  1233. for lh, rh, force_ref in selected_refs:
  1234. if lh is None:
  1235. new_refs[rh] = ZERO_SHA
  1236. remote_changed_refs[rh] = None
  1237. else:
  1238. try:
  1239. localsha = r.refs[lh]
  1240. except KeyError as exc:
  1241. raise Error(f"No valid ref {lh} in local repository") from exc
  1242. if not force_ref and rh in refs:
  1243. check_diverged(r, refs[rh], localsha)
  1244. new_refs[rh] = localsha
  1245. remote_changed_refs[rh] = localsha
  1246. return new_refs
  1247. err_encoding = getattr(errstream, "encoding", None) or DEFAULT_ENCODING
  1248. remote_location = client.get_url(path)
  1249. try:
  1250. result = client.send_pack(
  1251. path,
  1252. update_refs,
  1253. generate_pack_data=r.generate_pack_data,
  1254. progress=errstream.write,
  1255. )
  1256. except SendPackError as exc:
  1257. raise Error(
  1258. "Push to " + remote_location + " failed -> " + exc.args[0].decode(),
  1259. ) from exc
  1260. else:
  1261. errstream.write(
  1262. b"Push to " + remote_location.encode(err_encoding) + b" successful.\n"
  1263. )
  1264. for ref, error in (result.ref_status or {}).items():
  1265. if error is not None:
  1266. errstream.write(
  1267. b"Push of ref %s failed: %s\n" % (ref, error.encode(err_encoding))
  1268. )
  1269. else:
  1270. errstream.write(b"Ref %s updated\n" % ref)
  1271. if remote_name is not None:
  1272. _import_remote_refs(r.refs, remote_name, remote_changed_refs)
  1273. def pull(
  1274. repo,
  1275. remote_location=None,
  1276. refspecs=None,
  1277. outstream=default_bytes_out_stream,
  1278. errstream=default_bytes_err_stream,
  1279. fast_forward=True,
  1280. ff_only=False,
  1281. force=False,
  1282. filter_spec=None,
  1283. protocol_version=None,
  1284. **kwargs,
  1285. ) -> None:
  1286. """Pull from remote via dulwich.client.
  1287. Args:
  1288. repo: Path to repository
  1289. remote_location: Location of the remote
  1290. refspecs: refspecs to fetch. Can be a bytestring, a string, or a list of
  1291. bytestring/string.
  1292. outstream: A stream file to write to output
  1293. errstream: A stream file to write to errors
  1294. fast_forward: If True, raise an exception when fast-forward is not possible
  1295. ff_only: If True, only allow fast-forward merges. Raises DivergedBranches
  1296. when branches have diverged rather than performing a merge.
  1297. filter_spec: A git-rev-list-style object filter spec, as an ASCII string.
  1298. Only used if the server supports the Git protocol-v2 'filter'
  1299. feature, and ignored otherwise.
  1300. protocol_version: desired Git protocol version. By default the highest
  1301. mutually supported protocol version will be used
  1302. """
  1303. # Open the repo
  1304. with open_repo_closing(repo) as r:
  1305. (remote_name, remote_location) = get_remote_repo(r, remote_location)
  1306. selected_refs = []
  1307. if refspecs is None:
  1308. refspecs = [b"HEAD"]
  1309. def determine_wants(remote_refs, *args, **kwargs):
  1310. selected_refs.extend(
  1311. parse_reftuples(remote_refs, r.refs, refspecs, force=force)
  1312. )
  1313. return [
  1314. remote_refs[lh]
  1315. for (lh, rh, force_ref) in selected_refs
  1316. if remote_refs[lh] not in r.object_store
  1317. ]
  1318. client, path = get_transport_and_path(
  1319. remote_location, config=r.get_config_stack(), **kwargs
  1320. )
  1321. if filter_spec:
  1322. filter_spec = filter_spec.encode("ascii")
  1323. fetch_result = client.fetch(
  1324. path,
  1325. r,
  1326. progress=errstream.write,
  1327. determine_wants=determine_wants,
  1328. filter_spec=filter_spec,
  1329. protocol_version=protocol_version,
  1330. )
  1331. # Store the old HEAD tree before making changes
  1332. try:
  1333. old_head = r.refs[b"HEAD"]
  1334. old_tree_id = r[old_head].tree
  1335. except KeyError:
  1336. old_tree_id = None
  1337. merged = False
  1338. for lh, rh, force_ref in selected_refs:
  1339. if not force_ref and rh in r.refs:
  1340. try:
  1341. check_diverged(r, r.refs.follow(rh)[1], fetch_result.refs[lh])
  1342. except DivergedBranches as exc:
  1343. if ff_only or fast_forward:
  1344. raise
  1345. else:
  1346. # Perform merge
  1347. merge_result, conflicts = _do_merge(r, fetch_result.refs[lh])
  1348. if conflicts:
  1349. raise Error(
  1350. f"Merge conflicts occurred: {conflicts}"
  1351. ) from exc
  1352. merged = True
  1353. # Skip updating ref since merge already updated HEAD
  1354. continue
  1355. r.refs[rh] = fetch_result.refs[lh]
  1356. # Only update HEAD if we didn't perform a merge
  1357. if selected_refs and not merged:
  1358. r[b"HEAD"] = fetch_result.refs[selected_refs[0][1]]
  1359. # Update working tree to match the new HEAD
  1360. # Skip if merge was performed as merge already updates the working tree
  1361. if not merged and old_tree_id is not None:
  1362. new_tree_id = r[b"HEAD"].tree
  1363. update_working_tree(r, old_tree_id, new_tree_id)
  1364. if remote_name is not None:
  1365. _import_remote_refs(r.refs, remote_name, fetch_result.refs)
  1366. def status(repo=".", ignored=False, untracked_files="all"):
  1367. """Returns staged, unstaged, and untracked changes relative to the HEAD.
  1368. Args:
  1369. repo: Path to repository or repository object
  1370. ignored: Whether to include ignored files in untracked
  1371. untracked_files: How to handle untracked files, defaults to "all":
  1372. "no": do not return untracked files
  1373. "all": include all files in untracked directories
  1374. Using untracked_files="no" can be faster than "all" when the worktreee
  1375. contains many untracked files/directories.
  1376. Note: untracked_files="normal" (git's default) is not implemented.
  1377. Returns: GitStatus tuple,
  1378. staged - dict with lists of staged paths (diff index/HEAD)
  1379. unstaged - list of unstaged paths (diff index/working-tree)
  1380. untracked - list of untracked, un-ignored & non-.git paths
  1381. """
  1382. with open_repo_closing(repo) as r:
  1383. # 1. Get status of staged
  1384. tracked_changes = get_tree_changes(r)
  1385. # 2. Get status of unstaged
  1386. index = r.open_index()
  1387. normalizer = r.get_blob_normalizer()
  1388. filter_callback = normalizer.checkin_normalize
  1389. unstaged_changes = list(get_unstaged_changes(index, r.path, filter_callback))
  1390. untracked_paths = get_untracked_paths(
  1391. r.path,
  1392. r.path,
  1393. index,
  1394. exclude_ignored=not ignored,
  1395. untracked_files=untracked_files,
  1396. )
  1397. if sys.platform == "win32":
  1398. untracked_changes = [
  1399. path.replace(os.path.sep, "/") for path in untracked_paths
  1400. ]
  1401. else:
  1402. untracked_changes = list(untracked_paths)
  1403. return GitStatus(tracked_changes, unstaged_changes, untracked_changes)
  1404. def _walk_working_dir_paths(frompath, basepath, prune_dirnames=None):
  1405. """Get path, is_dir for files in working dir from frompath.
  1406. Args:
  1407. frompath: Path to begin walk
  1408. basepath: Path to compare to
  1409. prune_dirnames: Optional callback to prune dirnames during os.walk
  1410. dirnames will be set to result of prune_dirnames(dirpath, dirnames)
  1411. """
  1412. for dirpath, dirnames, filenames in os.walk(frompath):
  1413. # Skip .git and below.
  1414. if ".git" in dirnames:
  1415. dirnames.remove(".git")
  1416. if dirpath != basepath:
  1417. continue
  1418. if ".git" in filenames:
  1419. filenames.remove(".git")
  1420. if dirpath != basepath:
  1421. continue
  1422. if dirpath != frompath:
  1423. yield dirpath, True
  1424. for filename in filenames:
  1425. filepath = os.path.join(dirpath, filename)
  1426. yield filepath, False
  1427. if prune_dirnames:
  1428. dirnames[:] = prune_dirnames(dirpath, dirnames)
  1429. def get_untracked_paths(
  1430. frompath, basepath, index, exclude_ignored=False, untracked_files="all"
  1431. ):
  1432. """Get untracked paths.
  1433. Args:
  1434. frompath: Path to walk
  1435. basepath: Path to compare to
  1436. index: Index to check against
  1437. exclude_ignored: Whether to exclude ignored paths
  1438. untracked_files: How to handle untracked files:
  1439. - "no": return an empty list
  1440. - "all": return all files in untracked directories
  1441. - "normal": Not implemented
  1442. Note: ignored directories will never be walked for performance reasons.
  1443. If exclude_ignored is False, only the path to an ignored directory will
  1444. be yielded, no files inside the directory will be returned
  1445. """
  1446. if untracked_files == "normal":
  1447. raise NotImplementedError("normal is not yet supported")
  1448. if untracked_files not in ("no", "all"):
  1449. raise ValueError("untracked_files must be one of (no, all)")
  1450. if untracked_files == "no":
  1451. return
  1452. with open_repo_closing(basepath) as r:
  1453. ignore_manager = IgnoreFilterManager.from_repo(r)
  1454. ignored_dirs = []
  1455. def prune_dirnames(dirpath, dirnames):
  1456. for i in range(len(dirnames) - 1, -1, -1):
  1457. path = os.path.join(dirpath, dirnames[i])
  1458. ip = os.path.join(os.path.relpath(path, basepath), "")
  1459. if ignore_manager.is_ignored(ip):
  1460. if not exclude_ignored:
  1461. ignored_dirs.append(
  1462. os.path.join(os.path.relpath(path, frompath), "")
  1463. )
  1464. del dirnames[i]
  1465. return dirnames
  1466. for ap, is_dir in _walk_working_dir_paths(
  1467. frompath, basepath, prune_dirnames=prune_dirnames
  1468. ):
  1469. if not is_dir:
  1470. ip = path_to_tree_path(basepath, ap)
  1471. if ip not in index:
  1472. if not exclude_ignored or not ignore_manager.is_ignored(
  1473. os.path.relpath(ap, basepath)
  1474. ):
  1475. yield os.path.relpath(ap, frompath)
  1476. yield from ignored_dirs
  1477. def get_tree_changes(repo):
  1478. """Return add/delete/modify changes to tree by comparing index to HEAD.
  1479. Args:
  1480. repo: repo path or object
  1481. Returns: dict with lists for each type of change
  1482. """
  1483. with open_repo_closing(repo) as r:
  1484. index = r.open_index()
  1485. # Compares the Index to the HEAD & determines changes
  1486. # Iterate through the changes and report add/delete/modify
  1487. # TODO: call out to dulwich.diff_tree somehow.
  1488. tracked_changes = {
  1489. "add": [],
  1490. "delete": [],
  1491. "modify": [],
  1492. }
  1493. try:
  1494. tree_id = r[b"HEAD"].tree
  1495. except KeyError:
  1496. tree_id = None
  1497. for change in index.changes_from_tree(r.object_store, tree_id):
  1498. if not change[0][0]:
  1499. tracked_changes["add"].append(change[0][1])
  1500. elif not change[0][1]:
  1501. tracked_changes["delete"].append(change[0][0])
  1502. elif change[0][0] == change[0][1]:
  1503. tracked_changes["modify"].append(change[0][0])
  1504. else:
  1505. raise NotImplementedError("git mv ops not yet supported")
  1506. return tracked_changes
  1507. def daemon(path=".", address=None, port=None) -> None:
  1508. """Run a daemon serving Git requests over TCP/IP.
  1509. Args:
  1510. path: Path to the directory to serve.
  1511. address: Optional address to listen on (defaults to ::)
  1512. port: Optional port to listen on (defaults to TCP_GIT_PORT)
  1513. """
  1514. # TODO(jelmer): Support git-daemon-export-ok and --export-all.
  1515. backend = FileSystemBackend(path)
  1516. server = TCPGitServer(backend, address, port)
  1517. server.serve_forever()
  1518. def web_daemon(path=".", address=None, port=None) -> None:
  1519. """Run a daemon serving Git requests over HTTP.
  1520. Args:
  1521. path: Path to the directory to serve
  1522. address: Optional address to listen on (defaults to ::)
  1523. port: Optional port to listen on (defaults to 80)
  1524. """
  1525. from .web import (
  1526. WSGIRequestHandlerLogger,
  1527. WSGIServerLogger,
  1528. make_server,
  1529. make_wsgi_chain,
  1530. )
  1531. backend = FileSystemBackend(path)
  1532. app = make_wsgi_chain(backend)
  1533. server = make_server(
  1534. address,
  1535. port,
  1536. app,
  1537. handler_class=WSGIRequestHandlerLogger,
  1538. server_class=WSGIServerLogger,
  1539. )
  1540. server.serve_forever()
  1541. def upload_pack(path=".", inf=None, outf=None) -> int:
  1542. """Upload a pack file after negotiating its contents using smart protocol.
  1543. Args:
  1544. path: Path to the repository
  1545. inf: Input stream to communicate with client
  1546. outf: Output stream to communicate with client
  1547. """
  1548. if outf is None:
  1549. outf = getattr(sys.stdout, "buffer", sys.stdout)
  1550. if inf is None:
  1551. inf = getattr(sys.stdin, "buffer", sys.stdin)
  1552. path = os.path.expanduser(path)
  1553. backend = FileSystemBackend(path)
  1554. def send_fn(data) -> None:
  1555. outf.write(data)
  1556. outf.flush()
  1557. proto = Protocol(inf.read, send_fn)
  1558. handler = UploadPackHandler(backend, [path], proto)
  1559. # FIXME: Catch exceptions and write a single-line summary to outf.
  1560. handler.handle()
  1561. return 0
  1562. def receive_pack(path=".", inf=None, outf=None) -> int:
  1563. """Receive a pack file after negotiating its contents using smart protocol.
  1564. Args:
  1565. path: Path to the repository
  1566. inf: Input stream to communicate with client
  1567. outf: Output stream to communicate with client
  1568. """
  1569. if outf is None:
  1570. outf = getattr(sys.stdout, "buffer", sys.stdout)
  1571. if inf is None:
  1572. inf = getattr(sys.stdin, "buffer", sys.stdin)
  1573. path = os.path.expanduser(path)
  1574. backend = FileSystemBackend(path)
  1575. def send_fn(data) -> None:
  1576. outf.write(data)
  1577. outf.flush()
  1578. proto = Protocol(inf.read, send_fn)
  1579. handler = ReceivePackHandler(backend, [path], proto)
  1580. # FIXME: Catch exceptions and write a single-line summary to outf.
  1581. handler.handle()
  1582. return 0
  1583. def _make_branch_ref(name: Union[str, bytes]) -> Ref:
  1584. if isinstance(name, str):
  1585. name = name.encode(DEFAULT_ENCODING)
  1586. return LOCAL_BRANCH_PREFIX + name
  1587. def _make_tag_ref(name: Union[str, bytes]) -> Ref:
  1588. if isinstance(name, str):
  1589. name = name.encode(DEFAULT_ENCODING)
  1590. return LOCAL_TAG_PREFIX + name
  1591. def branch_delete(repo, name) -> None:
  1592. """Delete a branch.
  1593. Args:
  1594. repo: Path to the repository
  1595. name: Name of the branch
  1596. """
  1597. with open_repo_closing(repo) as r:
  1598. if isinstance(name, list):
  1599. names = name
  1600. else:
  1601. names = [name]
  1602. for name in names:
  1603. del r.refs[_make_branch_ref(name)]
  1604. def branch_create(repo, name, objectish=None, force=False) -> None:
  1605. """Create a branch.
  1606. Args:
  1607. repo: Path to the repository
  1608. name: Name of the new branch
  1609. objectish: Target object to point new branch at (defaults to HEAD)
  1610. force: Force creation of branch, even if it already exists
  1611. """
  1612. with open_repo_closing(repo) as r:
  1613. if objectish is None:
  1614. objectish = "HEAD"
  1615. object = parse_object(r, objectish)
  1616. refname = _make_branch_ref(name)
  1617. ref_message = b"branch: Created from " + objectish.encode(DEFAULT_ENCODING)
  1618. if force:
  1619. r.refs.set_if_equals(refname, None, object.id, message=ref_message)
  1620. else:
  1621. if not r.refs.add_if_new(refname, object.id, message=ref_message):
  1622. raise Error(f"Branch with name {name} already exists.")
  1623. def branch_list(repo):
  1624. """List all branches.
  1625. Args:
  1626. repo: Path to the repository
  1627. """
  1628. with open_repo_closing(repo) as r:
  1629. return r.refs.keys(base=LOCAL_BRANCH_PREFIX)
  1630. def active_branch(repo):
  1631. """Return the active branch in the repository, if any.
  1632. Args:
  1633. repo: Repository to open
  1634. Returns:
  1635. branch name
  1636. Raises:
  1637. KeyError: if the repository does not have a working tree
  1638. IndexError: if HEAD is floating
  1639. """
  1640. with open_repo_closing(repo) as r:
  1641. active_ref = r.refs.follow(b"HEAD")[0][1]
  1642. if not active_ref.startswith(LOCAL_BRANCH_PREFIX):
  1643. raise ValueError(active_ref)
  1644. return active_ref[len(LOCAL_BRANCH_PREFIX) :]
  1645. def get_branch_remote(repo):
  1646. """Return the active branch's remote name, if any.
  1647. Args:
  1648. repo: Repository to open
  1649. Returns:
  1650. remote name
  1651. Raises:
  1652. KeyError: if the repository does not have a working tree
  1653. """
  1654. with open_repo_closing(repo) as r:
  1655. branch_name = active_branch(r.path)
  1656. config = r.get_config()
  1657. try:
  1658. remote_name = config.get((b"branch", branch_name), b"remote")
  1659. except KeyError:
  1660. remote_name = b"origin"
  1661. return remote_name
  1662. def fetch(
  1663. repo,
  1664. remote_location=None,
  1665. outstream=sys.stdout,
  1666. errstream=default_bytes_err_stream,
  1667. message=None,
  1668. depth=None,
  1669. prune=False,
  1670. prune_tags=False,
  1671. force=False,
  1672. **kwargs,
  1673. ):
  1674. """Fetch objects from a remote server.
  1675. Args:
  1676. repo: Path to the repository
  1677. remote_location: String identifying a remote server
  1678. outstream: Output stream (defaults to stdout)
  1679. errstream: Error stream (defaults to stderr)
  1680. message: Reflog message (defaults to b"fetch: from <remote_name>")
  1681. depth: Depth to fetch at
  1682. prune: Prune remote removed refs
  1683. prune_tags: Prune reomte removed tags
  1684. Returns:
  1685. Dictionary with refs on the remote
  1686. """
  1687. with open_repo_closing(repo) as r:
  1688. (remote_name, remote_location) = get_remote_repo(r, remote_location)
  1689. if message is None:
  1690. message = b"fetch: from " + remote_location.encode(DEFAULT_ENCODING)
  1691. client, path = get_transport_and_path(
  1692. remote_location, config=r.get_config_stack(), **kwargs
  1693. )
  1694. fetch_result = client.fetch(path, r, progress=errstream.write, depth=depth)
  1695. if remote_name is not None:
  1696. _import_remote_refs(
  1697. r.refs,
  1698. remote_name,
  1699. fetch_result.refs,
  1700. message,
  1701. prune=prune,
  1702. prune_tags=prune_tags,
  1703. )
  1704. return fetch_result
  1705. def for_each_ref(
  1706. repo: Union[Repo, str] = ".",
  1707. pattern: Optional[Union[str, bytes]] = None,
  1708. ) -> list[tuple[bytes, bytes, bytes]]:
  1709. """Iterate over all refs that match the (optional) pattern.
  1710. Args:
  1711. repo: Path to the repository
  1712. pattern: Optional glob (7) patterns to filter the refs with
  1713. Returns: List of bytes tuples with: (sha, object_type, ref_name)
  1714. """
  1715. if isinstance(pattern, str):
  1716. pattern = os.fsencode(pattern)
  1717. with open_repo_closing(repo) as r:
  1718. refs = r.get_refs()
  1719. if pattern:
  1720. matching_refs: dict[bytes, bytes] = {}
  1721. pattern_parts = pattern.split(b"/")
  1722. for ref, sha in refs.items():
  1723. matches = False
  1724. # git for-each-ref uses glob (7) style patterns, but fnmatch
  1725. # is greedy and also matches slashes, unlike glob.glob.
  1726. # We have to check parts of the pattern individually.
  1727. # See https://github.com/python/cpython/issues/72904
  1728. ref_parts = ref.split(b"/")
  1729. if len(ref_parts) > len(pattern_parts):
  1730. continue
  1731. for pat, ref_part in zip(pattern_parts, ref_parts):
  1732. matches = fnmatch.fnmatchcase(ref_part, pat)
  1733. if not matches:
  1734. break
  1735. if matches:
  1736. matching_refs[ref] = sha
  1737. refs = matching_refs
  1738. ret: list[tuple[bytes, bytes, bytes]] = [
  1739. (sha, r.get_object(sha).type_name, ref)
  1740. for ref, sha in sorted(
  1741. refs.items(),
  1742. key=lambda ref_sha: ref_sha[0],
  1743. )
  1744. if ref != b"HEAD"
  1745. ]
  1746. return ret
  1747. def ls_remote(remote, config: Optional[Config] = None, **kwargs):
  1748. """List the refs in a remote.
  1749. Args:
  1750. remote: Remote repository location
  1751. config: Configuration to use
  1752. Returns:
  1753. LsRemoteResult object with refs and symrefs
  1754. """
  1755. if config is None:
  1756. config = StackedConfig.default()
  1757. client, host_path = get_transport_and_path(remote, config=config, **kwargs)
  1758. return client.get_refs(host_path)
  1759. def repack(repo) -> None:
  1760. """Repack loose files in a repository.
  1761. Currently this only packs loose objects.
  1762. Args:
  1763. repo: Path to the repository
  1764. """
  1765. with open_repo_closing(repo) as r:
  1766. r.object_store.pack_loose_objects()
  1767. def pack_objects(
  1768. repo,
  1769. object_ids,
  1770. packf,
  1771. idxf,
  1772. delta_window_size=None,
  1773. deltify=None,
  1774. reuse_deltas=True,
  1775. pack_index_version=None,
  1776. ) -> None:
  1777. """Pack objects into a file.
  1778. Args:
  1779. repo: Path to the repository
  1780. object_ids: List of object ids to write
  1781. packf: File-like object to write to
  1782. idxf: File-like object to write to (can be None)
  1783. delta_window_size: Sliding window size for searching for deltas;
  1784. Set to None for default window size.
  1785. deltify: Whether to deltify objects
  1786. reuse_deltas: Allow reuse of existing deltas while deltifying
  1787. pack_index_version: Pack index version to use (1, 2, or 3). If None, uses default version.
  1788. """
  1789. with open_repo_closing(repo) as r:
  1790. entries, data_sum = write_pack_from_container(
  1791. packf.write,
  1792. r.object_store,
  1793. [(oid, None) for oid in object_ids],
  1794. deltify=deltify,
  1795. delta_window_size=delta_window_size,
  1796. reuse_deltas=reuse_deltas,
  1797. )
  1798. if idxf is not None:
  1799. entries = sorted([(k, v[0], v[1]) for (k, v) in entries.items()])
  1800. write_pack_index(idxf, entries, data_sum, version=pack_index_version)
  1801. def ls_tree(
  1802. repo,
  1803. treeish=b"HEAD",
  1804. outstream=sys.stdout,
  1805. recursive=False,
  1806. name_only=False,
  1807. ) -> None:
  1808. """List contents of a tree.
  1809. Args:
  1810. repo: Path to the repository
  1811. treeish: Tree id to list
  1812. outstream: Output stream (defaults to stdout)
  1813. recursive: Whether to recursively list files
  1814. name_only: Only print item name
  1815. """
  1816. def list_tree(store, treeid, base) -> None:
  1817. for name, mode, sha in store[treeid].iteritems():
  1818. if base:
  1819. name = posixpath.join(base, name)
  1820. if name_only:
  1821. outstream.write(name + b"\n")
  1822. else:
  1823. outstream.write(pretty_format_tree_entry(name, mode, sha))
  1824. if stat.S_ISDIR(mode) and recursive:
  1825. list_tree(store, sha, name)
  1826. with open_repo_closing(repo) as r:
  1827. tree = parse_tree(r, treeish)
  1828. list_tree(r.object_store, tree.id, "")
  1829. def remote_add(repo, name: Union[bytes, str], url: Union[bytes, str]) -> None:
  1830. """Add a remote.
  1831. Args:
  1832. repo: Path to the repository
  1833. name: Remote name
  1834. url: Remote URL
  1835. """
  1836. if not isinstance(name, bytes):
  1837. name = name.encode(DEFAULT_ENCODING)
  1838. if not isinstance(url, bytes):
  1839. url = url.encode(DEFAULT_ENCODING)
  1840. with open_repo_closing(repo) as r:
  1841. c = r.get_config()
  1842. section = (b"remote", name)
  1843. if c.has_section(section):
  1844. raise RemoteExists(section)
  1845. c.set(section, b"url", url)
  1846. c.write_to_path()
  1847. def remote_remove(repo: Repo, name: Union[bytes, str]) -> None:
  1848. """Remove a remote.
  1849. Args:
  1850. repo: Path to the repository
  1851. name: Remote name
  1852. """
  1853. if not isinstance(name, bytes):
  1854. name = name.encode(DEFAULT_ENCODING)
  1855. with open_repo_closing(repo) as r:
  1856. c = r.get_config()
  1857. section = (b"remote", name)
  1858. del c[section]
  1859. c.write_to_path()
  1860. def _quote_path(path: str) -> str:
  1861. """Quote a path using C-style quoting similar to git's core.quotePath.
  1862. Args:
  1863. path: Path to quote
  1864. Returns:
  1865. Quoted path string
  1866. """
  1867. # Check if path needs quoting (non-ASCII or special characters)
  1868. needs_quoting = False
  1869. for char in path:
  1870. if ord(char) > 127 or char in '"\\':
  1871. needs_quoting = True
  1872. break
  1873. if not needs_quoting:
  1874. return path
  1875. # Apply C-style quoting
  1876. quoted = '"'
  1877. for char in path:
  1878. if ord(char) > 127:
  1879. # Non-ASCII character, encode as octal escape
  1880. utf8_bytes = char.encode("utf-8")
  1881. for byte in utf8_bytes:
  1882. quoted += f"\\{byte:03o}"
  1883. elif char == '"':
  1884. quoted += '\\"'
  1885. elif char == "\\":
  1886. quoted += "\\\\"
  1887. else:
  1888. quoted += char
  1889. quoted += '"'
  1890. return quoted
  1891. def check_ignore(repo, paths, no_index=False, quote_path=True):
  1892. r"""Debug gitignore files.
  1893. Args:
  1894. repo: Path to the repository
  1895. paths: List of paths to check for
  1896. no_index: Don't check index
  1897. quote_path: If True, quote non-ASCII characters in returned paths using
  1898. C-style octal escapes (e.g. "тест.txt" becomes "\\321\\202\\320\\265\\321\\201\\321\\202.txt").
  1899. If False, return raw unicode paths.
  1900. Returns: List of ignored files
  1901. """
  1902. with open_repo_closing(repo) as r:
  1903. index = r.open_index()
  1904. ignore_manager = IgnoreFilterManager.from_repo(r)
  1905. for original_path in paths:
  1906. if not no_index and path_to_tree_path(r.path, original_path) in index:
  1907. continue
  1908. # Preserve whether the original path had a trailing slash
  1909. had_trailing_slash = original_path.endswith("/")
  1910. if os.path.isabs(original_path):
  1911. path = os.path.relpath(original_path, r.path)
  1912. else:
  1913. path = original_path
  1914. # Restore trailing slash if it was in the original
  1915. if had_trailing_slash and not path.endswith("/"):
  1916. path = path + "/"
  1917. # For directories, check with trailing slash to get correct ignore behavior
  1918. test_path = path
  1919. path_without_slash = path.rstrip("/")
  1920. is_directory = os.path.isdir(os.path.join(r.path, path_without_slash))
  1921. # If this is a directory path, ensure we test it correctly
  1922. if is_directory and not path.endswith("/"):
  1923. test_path = path + "/"
  1924. if ignore_manager.is_ignored(test_path):
  1925. yield _quote_path(path) if quote_path else path
  1926. def update_head(repo, target, detached=False, new_branch=None) -> None:
  1927. """Update HEAD to point at a new branch/commit.
  1928. Note that this does not actually update the working tree.
  1929. Args:
  1930. repo: Path to the repository
  1931. detached: Create a detached head
  1932. target: Branch or committish to switch to
  1933. new_branch: New branch to create
  1934. """
  1935. with open_repo_closing(repo) as r:
  1936. if new_branch is not None:
  1937. to_set = _make_branch_ref(new_branch)
  1938. else:
  1939. to_set = b"HEAD"
  1940. if detached:
  1941. # TODO(jelmer): Provide some way so that the actual ref gets
  1942. # updated rather than what it points to, so the delete isn't
  1943. # necessary.
  1944. del r.refs[to_set]
  1945. r.refs[to_set] = parse_commit(r, target).id
  1946. else:
  1947. r.refs.set_symbolic_ref(to_set, parse_ref(r, target))
  1948. if new_branch is not None:
  1949. r.refs.set_symbolic_ref(b"HEAD", to_set)
  1950. def checkout(
  1951. repo,
  1952. target: Union[bytes, str],
  1953. force: bool = False,
  1954. new_branch: Optional[Union[bytes, str]] = None,
  1955. ) -> None:
  1956. """Switch to a branch or commit, updating both HEAD and the working tree.
  1957. This is similar to 'git checkout', allowing you to switch to a branch,
  1958. tag, or specific commit. Unlike update_head, this function also updates
  1959. the working tree to match the target.
  1960. Args:
  1961. repo: Path to repository or repository object
  1962. target: Branch name, tag, or commit SHA to checkout
  1963. force: Force checkout even if there are local changes
  1964. new_branch: Create a new branch at target (like git checkout -b)
  1965. Raises:
  1966. CheckoutError: If checkout cannot be performed due to conflicts
  1967. KeyError: If the target reference cannot be found
  1968. """
  1969. with open_repo_closing(repo) as r:
  1970. if isinstance(target, str):
  1971. target = target.encode(DEFAULT_ENCODING)
  1972. if isinstance(new_branch, str):
  1973. new_branch = new_branch.encode(DEFAULT_ENCODING)
  1974. # Parse the target to get the commit
  1975. target_commit = parse_commit(r, target)
  1976. target_tree_id = target_commit.tree
  1977. # Get current HEAD tree for comparison
  1978. try:
  1979. current_head = r.refs[b"HEAD"]
  1980. current_tree_id = r[current_head].tree
  1981. except KeyError:
  1982. # No HEAD yet (empty repo)
  1983. current_tree_id = None
  1984. # Check for uncommitted changes if not forcing
  1985. if not force and current_tree_id is not None:
  1986. status_report = status(r)
  1987. changes = []
  1988. # staged is a dict with 'add', 'delete', 'modify' keys
  1989. if isinstance(status_report.staged, dict):
  1990. changes.extend(status_report.staged.get("add", []))
  1991. changes.extend(status_report.staged.get("delete", []))
  1992. changes.extend(status_report.staged.get("modify", []))
  1993. # unstaged is a list
  1994. changes.extend(status_report.unstaged)
  1995. if changes:
  1996. # Check if any changes would conflict with checkout
  1997. target_tree = r[target_tree_id]
  1998. for change in changes:
  1999. if isinstance(change, str):
  2000. change = change.encode(DEFAULT_ENCODING)
  2001. try:
  2002. target_tree.lookup_path(r.object_store.__getitem__, change)
  2003. # File exists in target tree - would overwrite local changes
  2004. raise CheckoutError(
  2005. f"Your local changes to '{change.decode()}' would be "
  2006. "overwritten by checkout. Please commit or stash before switching."
  2007. )
  2008. except KeyError:
  2009. # File doesn't exist in target tree - change can be preserved
  2010. pass
  2011. # Get configuration for working directory update
  2012. config = r.get_config()
  2013. honor_filemode = config.get_boolean(b"core", b"filemode", os.name != "nt")
  2014. # Import validation functions
  2015. from .index import validate_path_element_default, validate_path_element_ntfs
  2016. if config.get_boolean(b"core", b"core.protectNTFS", os.name == "nt"):
  2017. validate_path_element = validate_path_element_ntfs
  2018. else:
  2019. validate_path_element = validate_path_element_default
  2020. if config.get_boolean(b"core", b"symlinks", True):
  2021. # Import symlink function
  2022. from .index import symlink
  2023. symlink_fn = symlink
  2024. else:
  2025. def symlink_fn(source, target) -> None: # type: ignore
  2026. mode = "w" + ("b" if isinstance(source, bytes) else "")
  2027. with open(target, mode) as f:
  2028. f.write(source)
  2029. # Update working tree
  2030. update_working_tree(
  2031. r,
  2032. current_tree_id,
  2033. target_tree_id,
  2034. honor_filemode=honor_filemode,
  2035. validate_path_element=validate_path_element,
  2036. symlink_fn=symlink_fn,
  2037. force_remove_untracked=force,
  2038. )
  2039. # Update HEAD
  2040. if new_branch:
  2041. # Create new branch and switch to it
  2042. branch_create(r, new_branch, objectish=target_commit.id.decode("ascii"))
  2043. update_head(r, new_branch)
  2044. else:
  2045. # Check if target is a branch name (with or without refs/heads/ prefix)
  2046. branch_ref = None
  2047. if target in r.refs.keys():
  2048. if target.startswith(LOCAL_BRANCH_PREFIX):
  2049. branch_ref = target
  2050. else:
  2051. # Try adding refs/heads/ prefix
  2052. potential_branch = _make_branch_ref(target)
  2053. if potential_branch in r.refs.keys():
  2054. branch_ref = potential_branch
  2055. if branch_ref:
  2056. # It's a branch - update HEAD symbolically
  2057. update_head(r, branch_ref)
  2058. else:
  2059. # It's a tag, other ref, or commit SHA - detached HEAD
  2060. update_head(r, target_commit.id.decode("ascii"), detached=True)
  2061. def reset_file(repo, file_path: str, target: bytes = b"HEAD", symlink_fn=None) -> None:
  2062. """Reset the file to specific commit or branch.
  2063. Args:
  2064. repo: dulwich Repo object
  2065. file_path: file to reset, relative to the repository path
  2066. target: branch or commit or b'HEAD' to reset
  2067. """
  2068. tree = parse_tree(repo, treeish=target)
  2069. tree_path = _fs_to_tree_path(file_path)
  2070. file_entry = tree.lookup_path(repo.object_store.__getitem__, tree_path)
  2071. full_path = os.path.join(os.fsencode(repo.path), tree_path)
  2072. blob = repo.object_store[file_entry[1]]
  2073. mode = file_entry[0]
  2074. build_file_from_blob(blob, mode, full_path, symlink_fn=symlink_fn)
  2075. @replace_me(since="0.22.9", remove_in="0.24.0")
  2076. def checkout_branch(repo, target: Union[bytes, str], force: bool = False) -> None:
  2077. """Switch branches or restore working tree files.
  2078. This is now a wrapper around the general checkout() function.
  2079. Preserved for backward compatibility.
  2080. Args:
  2081. repo: dulwich Repo object
  2082. target: branch name or commit sha to checkout
  2083. force: true or not to force checkout
  2084. """
  2085. # Simply delegate to the new checkout function
  2086. return checkout(repo, target, force=force)
  2087. def sparse_checkout(
  2088. repo, patterns=None, force: bool = False, cone: Union[bool, None] = None
  2089. ):
  2090. """Perform a sparse checkout in the repository (either 'full' or 'cone mode').
  2091. Perform sparse checkout in either 'cone' (directory-based) mode or
  2092. 'full pattern' (.gitignore) mode, depending on the ``cone`` parameter.
  2093. If ``cone`` is ``None``, the mode is inferred from the repository's
  2094. ``core.sparseCheckoutCone`` config setting.
  2095. Steps:
  2096. 1) If ``patterns`` is provided, write them to ``.git/info/sparse-checkout``.
  2097. 2) Determine which paths in the index are included vs. excluded.
  2098. - If ``cone=True``, use "cone-compatible" directory-based logic.
  2099. - If ``cone=False``, use standard .gitignore-style matching.
  2100. 3) Update the index's skip-worktree bits and add/remove files in
  2101. the working tree accordingly.
  2102. 4) If ``force=False``, refuse to remove files that have local modifications.
  2103. Args:
  2104. repo: Path to the repository or a Repo object.
  2105. patterns: Optional list of sparse-checkout patterns to write.
  2106. force: Whether to force removal of locally modified files (default False).
  2107. cone: Boolean indicating cone mode (True/False). If None, read from config.
  2108. Returns:
  2109. None
  2110. """
  2111. with open_repo_closing(repo) as repo_obj:
  2112. # --- 0) Possibly infer 'cone' from config ---
  2113. if cone is None:
  2114. cone = repo_obj.infer_cone_mode()
  2115. # --- 1) Read or write patterns ---
  2116. if patterns is None:
  2117. lines = repo_obj.get_sparse_checkout_patterns()
  2118. if lines is None:
  2119. raise Error("No sparse checkout patterns found.")
  2120. else:
  2121. lines = patterns
  2122. repo_obj.set_sparse_checkout_patterns(patterns)
  2123. # --- 2) Determine the set of included paths ---
  2124. included_paths = determine_included_paths(repo_obj, lines, cone)
  2125. # --- 3) Apply those results to the index & working tree ---
  2126. try:
  2127. apply_included_paths(repo_obj, included_paths, force=force)
  2128. except SparseCheckoutConflictError as exc:
  2129. raise CheckoutError(*exc.args) from exc
  2130. def cone_mode_init(repo):
  2131. """Initialize a repository to use sparse checkout in 'cone' mode.
  2132. Sets ``core.sparseCheckout`` and ``core.sparseCheckoutCone`` in the config.
  2133. Writes an initial ``.git/info/sparse-checkout`` file that includes only
  2134. top-level files (and excludes all subdirectories), e.g. ``["/*", "!/*/"]``.
  2135. Then performs a sparse checkout to update the working tree accordingly.
  2136. If no directories are specified, then only top-level files are included:
  2137. https://git-scm.com/docs/git-sparse-checkout#_internalscone_mode_handling
  2138. Args:
  2139. repo: Path to the repository or a Repo object.
  2140. Returns:
  2141. None
  2142. """
  2143. with open_repo_closing(repo) as repo_obj:
  2144. repo_obj.configure_for_cone_mode()
  2145. patterns = ["/*", "!/*/"] # root-level files only
  2146. sparse_checkout(repo_obj, patterns, force=True, cone=True)
  2147. def cone_mode_set(repo, dirs, force=False):
  2148. """Overwrite the existing 'cone-mode' sparse patterns with a new set of directories.
  2149. Ensures ``core.sparseCheckout`` and ``core.sparseCheckoutCone`` are enabled.
  2150. Writes new patterns so that only the specified directories (and top-level files)
  2151. remain in the working tree, and applies the sparse checkout update.
  2152. Args:
  2153. repo: Path to the repository or a Repo object.
  2154. dirs: List of directory names to include.
  2155. force: Whether to forcibly discard local modifications (default False).
  2156. Returns:
  2157. None
  2158. """
  2159. with open_repo_closing(repo) as repo_obj:
  2160. repo_obj.configure_for_cone_mode()
  2161. repo_obj.set_cone_mode_patterns(dirs=dirs)
  2162. new_patterns = repo_obj.get_sparse_checkout_patterns()
  2163. # Finally, apply the patterns and update the working tree
  2164. sparse_checkout(repo_obj, new_patterns, force=force, cone=True)
  2165. def cone_mode_add(repo, dirs, force=False):
  2166. """Add new directories to the existing 'cone-mode' sparse-checkout patterns.
  2167. Reads the current patterns from ``.git/info/sparse-checkout``, adds pattern
  2168. lines to include the specified directories, and then performs a sparse
  2169. checkout to update the working tree accordingly.
  2170. Args:
  2171. repo: Path to the repository or a Repo object.
  2172. dirs: List of directory names to add to the sparse-checkout.
  2173. force: Whether to forcibly discard local modifications (default False).
  2174. Returns:
  2175. None
  2176. """
  2177. with open_repo_closing(repo) as repo_obj:
  2178. repo_obj.configure_for_cone_mode()
  2179. # Do not pass base patterns as dirs
  2180. base_patterns = ["/*", "!/*/"]
  2181. existing_dirs = [
  2182. pat.strip("/")
  2183. for pat in repo_obj.get_sparse_checkout_patterns()
  2184. if pat not in base_patterns
  2185. ]
  2186. added_dirs = existing_dirs + (dirs or [])
  2187. repo_obj.set_cone_mode_patterns(dirs=added_dirs)
  2188. new_patterns = repo_obj.get_sparse_checkout_patterns()
  2189. sparse_checkout(repo_obj, patterns=new_patterns, force=force, cone=True)
  2190. def check_mailmap(repo, contact):
  2191. """Check canonical name and email of contact.
  2192. Args:
  2193. repo: Path to the repository
  2194. contact: Contact name and/or email
  2195. Returns: Canonical contact data
  2196. """
  2197. with open_repo_closing(repo) as r:
  2198. from .mailmap import Mailmap
  2199. try:
  2200. mailmap = Mailmap.from_path(os.path.join(r.path, ".mailmap"))
  2201. except FileNotFoundError:
  2202. mailmap = Mailmap()
  2203. return mailmap.lookup(contact)
  2204. def fsck(repo):
  2205. """Check a repository.
  2206. Args:
  2207. repo: A path to the repository
  2208. Returns: Iterator over errors/warnings
  2209. """
  2210. with open_repo_closing(repo) as r:
  2211. # TODO(jelmer): check pack files
  2212. # TODO(jelmer): check graph
  2213. # TODO(jelmer): check refs
  2214. for sha in r.object_store:
  2215. o = r.object_store[sha]
  2216. try:
  2217. o.check()
  2218. except Exception as e:
  2219. yield (sha, e)
  2220. def stash_list(repo):
  2221. """List all stashes in a repository."""
  2222. with open_repo_closing(repo) as r:
  2223. from .stash import Stash
  2224. stash = Stash.from_repo(r)
  2225. return enumerate(list(stash.stashes()))
  2226. def stash_push(repo) -> None:
  2227. """Push a new stash onto the stack."""
  2228. with open_repo_closing(repo) as r:
  2229. from .stash import Stash
  2230. stash = Stash.from_repo(r)
  2231. stash.push()
  2232. def stash_pop(repo) -> None:
  2233. """Pop a stash from the stack."""
  2234. with open_repo_closing(repo) as r:
  2235. from .stash import Stash
  2236. stash = Stash.from_repo(r)
  2237. stash.pop(0)
  2238. def stash_drop(repo, index) -> None:
  2239. """Drop a stash from the stack."""
  2240. with open_repo_closing(repo) as r:
  2241. from .stash import Stash
  2242. stash = Stash.from_repo(r)
  2243. stash.drop(index)
  2244. def ls_files(repo):
  2245. """List all files in an index."""
  2246. with open_repo_closing(repo) as r:
  2247. return sorted(r.open_index())
  2248. def find_unique_abbrev(object_store, object_id):
  2249. """For now, just return 7 characters."""
  2250. # TODO(jelmer): Add some logic here to return a number of characters that
  2251. # scales relative with the size of the repository
  2252. return object_id.decode("ascii")[:7]
  2253. def describe(repo, abbrev=None):
  2254. """Describe the repository version.
  2255. Args:
  2256. repo: git repository
  2257. abbrev: number of characters of commit to take, default is 7
  2258. Returns: a string description of the current git revision
  2259. Examples: "gabcdefh", "v0.1" or "v0.1-5-gabcdefh".
  2260. """
  2261. abbrev_slice = slice(0, abbrev if abbrev is not None else 7)
  2262. # Get the repository
  2263. with open_repo_closing(repo) as r:
  2264. # Get a list of all tags
  2265. refs = r.get_refs()
  2266. tags = {}
  2267. for key, value in refs.items():
  2268. key = key.decode()
  2269. obj = r.get_object(value)
  2270. if "tags" not in key:
  2271. continue
  2272. _, tag = key.rsplit("/", 1)
  2273. try:
  2274. # Annotated tag case
  2275. commit = obj.object
  2276. commit = r.get_object(commit[1])
  2277. except AttributeError:
  2278. # Lightweight tag case - obj is already the commit
  2279. commit = obj
  2280. tags[tag] = [
  2281. datetime.datetime(*time.gmtime(commit.commit_time)[:6]),
  2282. commit.id.decode("ascii"),
  2283. ]
  2284. sorted_tags = sorted(tags.items(), key=lambda tag: tag[1][0], reverse=True)
  2285. # Get the latest commit
  2286. latest_commit = r[r.head()]
  2287. # If there are no tags, return the latest commit
  2288. if len(sorted_tags) == 0:
  2289. if abbrev is not None:
  2290. return "g{}".format(latest_commit.id.decode("ascii")[abbrev_slice])
  2291. return f"g{find_unique_abbrev(r.object_store, latest_commit.id)}"
  2292. # We're now 0 commits from the top
  2293. commit_count = 0
  2294. # Walk through all commits
  2295. walker = r.get_walker()
  2296. for entry in walker:
  2297. # Check if tag
  2298. commit_id = entry.commit.id.decode("ascii")
  2299. for tag in sorted_tags:
  2300. tag_name = tag[0]
  2301. tag_commit = tag[1][1]
  2302. if commit_id == tag_commit:
  2303. if commit_count == 0:
  2304. return tag_name
  2305. else:
  2306. return "{}-{}-g{}".format(
  2307. tag_name,
  2308. commit_count,
  2309. latest_commit.id.decode("ascii")[abbrev_slice],
  2310. )
  2311. commit_count += 1
  2312. # Return plain commit if no parent tag can be found
  2313. return "g{}".format(latest_commit.id.decode("ascii")[abbrev_slice])
  2314. def get_object_by_path(repo, path, committish=None):
  2315. """Get an object by path.
  2316. Args:
  2317. repo: A path to the repository
  2318. path: Path to look up
  2319. committish: Commit to look up path in
  2320. Returns: A `ShaFile` object
  2321. """
  2322. if committish is None:
  2323. committish = "HEAD"
  2324. # Get the repository
  2325. with open_repo_closing(repo) as r:
  2326. commit = parse_commit(r, committish)
  2327. base_tree = commit.tree
  2328. if not isinstance(path, bytes):
  2329. path = commit_encode(commit, path)
  2330. (mode, sha) = tree_lookup_path(r.object_store.__getitem__, base_tree, path)
  2331. return r[sha]
  2332. def write_tree(repo):
  2333. """Write a tree object from the index.
  2334. Args:
  2335. repo: Repository for which to write tree
  2336. Returns: tree id for the tree that was written
  2337. """
  2338. with open_repo_closing(repo) as r:
  2339. return r.open_index().commit(r.object_store)
  2340. def _do_merge(
  2341. r,
  2342. merge_commit_id,
  2343. no_commit=False,
  2344. no_ff=False,
  2345. message=None,
  2346. author=None,
  2347. committer=None,
  2348. ):
  2349. """Internal merge implementation that operates on an open repository.
  2350. Args:
  2351. r: Open repository object
  2352. merge_commit_id: SHA of commit to merge
  2353. no_commit: If True, do not create a merge commit
  2354. no_ff: If True, force creation of a merge commit
  2355. message: Optional merge commit message
  2356. author: Optional author for merge commit
  2357. committer: Optional committer for merge commit
  2358. Returns:
  2359. Tuple of (merge_commit_sha, conflicts) where merge_commit_sha is None
  2360. if no_commit=True or there were conflicts
  2361. """
  2362. from .graph import find_merge_base
  2363. from .merge import three_way_merge
  2364. # Get HEAD commit
  2365. try:
  2366. head_commit_id = r.refs[b"HEAD"]
  2367. except KeyError:
  2368. raise Error("No HEAD reference found")
  2369. head_commit = r[head_commit_id]
  2370. merge_commit = r[merge_commit_id]
  2371. # Check if fast-forward is possible
  2372. merge_bases = find_merge_base(r, [head_commit_id, merge_commit_id])
  2373. if not merge_bases:
  2374. raise Error("No common ancestor found")
  2375. # Use the first merge base
  2376. base_commit_id = merge_bases[0]
  2377. # Check if we're trying to merge the same commit
  2378. if head_commit_id == merge_commit_id:
  2379. # Already up to date
  2380. return (None, [])
  2381. # Check for fast-forward
  2382. if base_commit_id == head_commit_id and not no_ff:
  2383. # Fast-forward merge
  2384. r.refs[b"HEAD"] = merge_commit_id
  2385. # Update the working directory
  2386. update_working_tree(r, head_commit.tree, merge_commit.tree)
  2387. return (merge_commit_id, [])
  2388. if base_commit_id == merge_commit_id:
  2389. # Already up to date
  2390. return (None, [])
  2391. # Perform three-way merge
  2392. base_commit = r[base_commit_id]
  2393. merged_tree, conflicts = three_way_merge(
  2394. r.object_store, base_commit, head_commit, merge_commit
  2395. )
  2396. # Add merged tree to object store
  2397. r.object_store.add_object(merged_tree)
  2398. # Update index and working directory
  2399. update_working_tree(r, head_commit.tree, merged_tree.id)
  2400. if conflicts or no_commit:
  2401. # Don't create a commit if there are conflicts or no_commit is True
  2402. return (None, conflicts)
  2403. # Create merge commit
  2404. merge_commit_obj = Commit()
  2405. merge_commit_obj.tree = merged_tree.id
  2406. merge_commit_obj.parents = [head_commit_id, merge_commit_id]
  2407. # Set author/committer
  2408. if author is None:
  2409. author = get_user_identity(r.get_config_stack())
  2410. if committer is None:
  2411. committer = author
  2412. merge_commit_obj.author = author
  2413. merge_commit_obj.committer = committer
  2414. # Set timestamps
  2415. timestamp = int(time.time())
  2416. timezone = 0 # UTC
  2417. merge_commit_obj.author_time = timestamp
  2418. merge_commit_obj.author_timezone = timezone
  2419. merge_commit_obj.commit_time = timestamp
  2420. merge_commit_obj.commit_timezone = timezone
  2421. # Set commit message
  2422. if message is None:
  2423. message = f"Merge commit '{merge_commit_id.decode()[:7]}'\n"
  2424. merge_commit_obj.message = message.encode() if isinstance(message, str) else message
  2425. # Add commit to object store
  2426. r.object_store.add_object(merge_commit_obj)
  2427. # Update HEAD
  2428. r.refs[b"HEAD"] = merge_commit_obj.id
  2429. return (merge_commit_obj.id, [])
  2430. def merge(
  2431. repo,
  2432. committish,
  2433. no_commit=False,
  2434. no_ff=False,
  2435. message=None,
  2436. author=None,
  2437. committer=None,
  2438. ):
  2439. """Merge a commit into the current branch.
  2440. Args:
  2441. repo: Repository to merge into
  2442. committish: Commit to merge
  2443. no_commit: If True, do not create a merge commit
  2444. no_ff: If True, force creation of a merge commit
  2445. message: Optional merge commit message
  2446. author: Optional author for merge commit
  2447. committer: Optional committer for merge commit
  2448. Returns:
  2449. Tuple of (merge_commit_sha, conflicts) where merge_commit_sha is None
  2450. if no_commit=True or there were conflicts
  2451. Raises:
  2452. Error: If there is no HEAD reference or commit cannot be found
  2453. """
  2454. with open_repo_closing(repo) as r:
  2455. # Parse the commit to merge
  2456. try:
  2457. merge_commit_id = parse_commit(r, committish).id
  2458. except KeyError:
  2459. raise Error(f"Cannot find commit '{committish}'")
  2460. return _do_merge(
  2461. r, merge_commit_id, no_commit, no_ff, message, author, committer
  2462. )
  2463. def unpack_objects(pack_path, target="."):
  2464. """Unpack objects from a pack file into the repository.
  2465. Args:
  2466. pack_path: Path to the pack file to unpack
  2467. target: Path to the repository to unpack into
  2468. Returns:
  2469. Number of objects unpacked
  2470. """
  2471. from .pack import Pack
  2472. with open_repo_closing(target) as r:
  2473. pack_basename = os.path.splitext(pack_path)[0]
  2474. with Pack(pack_basename) as pack:
  2475. count = 0
  2476. for unpacked in pack.iter_unpacked():
  2477. obj = unpacked.sha_file()
  2478. r.object_store.add_object(obj)
  2479. count += 1
  2480. return count
  2481. def merge_tree(repo, base_tree, our_tree, their_tree):
  2482. """Perform a three-way tree merge without touching the working directory.
  2483. This is similar to git merge-tree, performing a merge at the tree level
  2484. without creating commits or updating any references.
  2485. Args:
  2486. repo: Repository containing the trees
  2487. base_tree: Tree-ish of the common ancestor (or None for no common ancestor)
  2488. our_tree: Tree-ish of our side of the merge
  2489. their_tree: Tree-ish of their side of the merge
  2490. Returns:
  2491. tuple: A tuple of (merged_tree_id, conflicts) where:
  2492. - merged_tree_id is the SHA-1 of the merged tree
  2493. - conflicts is a list of paths (as bytes) that had conflicts
  2494. Raises:
  2495. KeyError: If any of the tree-ish arguments cannot be resolved
  2496. """
  2497. from .merge import Merger
  2498. with open_repo_closing(repo) as r:
  2499. # Resolve tree-ish arguments to actual trees
  2500. base = parse_tree(r, base_tree) if base_tree else None
  2501. ours = parse_tree(r, our_tree)
  2502. theirs = parse_tree(r, their_tree)
  2503. # Perform the merge
  2504. merger = Merger(r.object_store)
  2505. merged_tree, conflicts = merger.merge_trees(base, ours, theirs)
  2506. # Add the merged tree to the object store
  2507. r.object_store.add_object(merged_tree)
  2508. return merged_tree.id, conflicts
  2509. def cherry_pick(
  2510. repo,
  2511. committish,
  2512. no_commit=False,
  2513. continue_=False,
  2514. abort=False,
  2515. ):
  2516. r"""Cherry-pick a commit onto the current branch.
  2517. Args:
  2518. repo: Repository to cherry-pick into
  2519. committish: Commit to cherry-pick
  2520. no_commit: If True, do not create a commit after applying changes
  2521. continue\\_: Continue an in-progress cherry-pick after resolving conflicts
  2522. abort: Abort an in-progress cherry-pick
  2523. Returns:
  2524. The SHA of the newly created commit, or None if no_commit=True or there were conflicts
  2525. Raises:
  2526. Error: If there is no HEAD reference, commit cannot be found, or operation fails
  2527. """
  2528. from .merge import three_way_merge
  2529. with open_repo_closing(repo) as r:
  2530. # Handle abort
  2531. if abort:
  2532. # Clean up any cherry-pick state
  2533. try:
  2534. os.remove(os.path.join(r.controldir(), "CHERRY_PICK_HEAD"))
  2535. except FileNotFoundError:
  2536. pass
  2537. try:
  2538. os.remove(os.path.join(r.controldir(), "MERGE_MSG"))
  2539. except FileNotFoundError:
  2540. pass
  2541. # Reset index to HEAD
  2542. r.reset_index(r[b"HEAD"].tree)
  2543. return None
  2544. # Handle continue
  2545. if continue_:
  2546. # Check if there's a cherry-pick in progress
  2547. cherry_pick_head_path = os.path.join(r.controldir(), "CHERRY_PICK_HEAD")
  2548. try:
  2549. with open(cherry_pick_head_path, "rb") as f:
  2550. cherry_pick_commit_id = f.read().strip()
  2551. cherry_pick_commit = r[cherry_pick_commit_id]
  2552. except FileNotFoundError:
  2553. raise Error("No cherry-pick in progress")
  2554. # Check for unresolved conflicts
  2555. conflicts = list(r.open_index().conflicts())
  2556. if conflicts:
  2557. raise Error("Unresolved conflicts remain")
  2558. # Create the commit
  2559. tree_id = r.open_index().commit(r.object_store)
  2560. # Read saved message if any
  2561. merge_msg_path = os.path.join(r.controldir(), "MERGE_MSG")
  2562. try:
  2563. with open(merge_msg_path, "rb") as f:
  2564. message = f.read()
  2565. except FileNotFoundError:
  2566. message = cherry_pick_commit.message
  2567. new_commit = r.do_commit(
  2568. message=message,
  2569. tree=tree_id,
  2570. author=cherry_pick_commit.author,
  2571. author_timestamp=cherry_pick_commit.author_time,
  2572. author_timezone=cherry_pick_commit.author_timezone,
  2573. )
  2574. # Clean up state files
  2575. try:
  2576. os.remove(cherry_pick_head_path)
  2577. except FileNotFoundError:
  2578. pass
  2579. try:
  2580. os.remove(merge_msg_path)
  2581. except FileNotFoundError:
  2582. pass
  2583. return new_commit
  2584. # Normal cherry-pick operation
  2585. # Get current HEAD
  2586. try:
  2587. head_commit = r[b"HEAD"]
  2588. except KeyError:
  2589. raise Error("No HEAD reference found")
  2590. # Parse the commit to cherry-pick
  2591. try:
  2592. cherry_pick_commit = parse_commit(r, committish)
  2593. except KeyError:
  2594. raise Error(f"Cannot find commit '{committish}'")
  2595. # Check if commit has parents
  2596. if not cherry_pick_commit.parents:
  2597. raise Error("Cannot cherry-pick root commit")
  2598. # Get parent of cherry-pick commit
  2599. parent_commit = r[cherry_pick_commit.parents[0]]
  2600. # Perform three-way merge
  2601. try:
  2602. merged_tree, conflicts = three_way_merge(
  2603. r.object_store, parent_commit, head_commit, cherry_pick_commit
  2604. )
  2605. except Exception as e:
  2606. raise Error(f"Cherry-pick failed: {e}")
  2607. # Add merged tree to object store
  2608. r.object_store.add_object(merged_tree)
  2609. # Update working tree and index
  2610. # Reset index to match merged tree
  2611. r.reset_index(merged_tree.id)
  2612. # Update working tree from the new index
  2613. update_working_tree(r, head_commit.tree, merged_tree.id)
  2614. if conflicts:
  2615. # Save state for later continuation
  2616. with open(os.path.join(r.controldir(), "CHERRY_PICK_HEAD"), "wb") as f:
  2617. f.write(cherry_pick_commit.id + b"\n")
  2618. # Save commit message
  2619. with open(os.path.join(r.controldir(), "MERGE_MSG"), "wb") as f:
  2620. f.write(cherry_pick_commit.message)
  2621. raise Error(
  2622. f"Conflicts in: {', '.join(c.decode('utf-8', 'replace') for c in conflicts)}\n"
  2623. f"Fix conflicts and run 'dulwich cherry-pick --continue'"
  2624. )
  2625. if no_commit:
  2626. return None
  2627. # Create the commit
  2628. new_commit = r.do_commit(
  2629. message=cherry_pick_commit.message,
  2630. tree=merged_tree.id,
  2631. author=cherry_pick_commit.author,
  2632. author_timestamp=cherry_pick_commit.author_time,
  2633. author_timezone=cherry_pick_commit.author_timezone,
  2634. )
  2635. return new_commit
  2636. def revert(
  2637. repo,
  2638. commits,
  2639. no_commit=False,
  2640. message=None,
  2641. author=None,
  2642. committer=None,
  2643. ):
  2644. """Revert one or more commits.
  2645. This creates a new commit that undoes the changes introduced by the
  2646. specified commits. Unlike reset, revert creates a new commit that
  2647. preserves history.
  2648. Args:
  2649. repo: Path to repository or repository object
  2650. commits: List of commit-ish (SHA, ref, etc.) to revert, or a single commit-ish
  2651. no_commit: If True, apply changes to index/working tree but don't commit
  2652. message: Optional commit message (default: "Revert <original subject>")
  2653. author: Optional author for revert commit
  2654. committer: Optional committer for revert commit
  2655. Returns:
  2656. SHA1 of the new revert commit, or None if no_commit=True
  2657. Raises:
  2658. Error: If revert fails due to conflicts or other issues
  2659. """
  2660. from .merge import three_way_merge
  2661. # Normalize commits to a list
  2662. if isinstance(commits, (str, bytes)):
  2663. commits = [commits]
  2664. with open_repo_closing(repo) as r:
  2665. # Convert string refs to bytes
  2666. commits_to_revert = []
  2667. for commit_ref in commits:
  2668. if isinstance(commit_ref, str):
  2669. commit_ref = commit_ref.encode("utf-8")
  2670. commit = parse_commit(r, commit_ref)
  2671. commits_to_revert.append(commit)
  2672. # Get current HEAD
  2673. try:
  2674. head_commit_id = r.refs[b"HEAD"]
  2675. except KeyError:
  2676. raise Error("No HEAD reference found")
  2677. head_commit = r[head_commit_id]
  2678. current_tree = head_commit.tree
  2679. # Process commits in order
  2680. for commit_to_revert in commits_to_revert:
  2681. # For revert, we want to apply the inverse of the commit
  2682. # This means using the commit's tree as "base" and its parent as "theirs"
  2683. if not commit_to_revert.parents:
  2684. raise Error(
  2685. f"Cannot revert commit {commit_to_revert.id} - it has no parents"
  2686. )
  2687. # For simplicity, we only handle commits with one parent (no merge commits)
  2688. if len(commit_to_revert.parents) > 1:
  2689. raise Error(
  2690. f"Cannot revert merge commit {commit_to_revert.id} - not yet implemented"
  2691. )
  2692. parent_commit = r[commit_to_revert.parents[0]]
  2693. # Perform three-way merge:
  2694. # - base: the commit we're reverting (what we want to remove)
  2695. # - ours: current HEAD (what we have now)
  2696. # - theirs: parent of commit being reverted (what we want to go back to)
  2697. merged_tree, conflicts = three_way_merge(
  2698. r.object_store,
  2699. commit_to_revert, # base
  2700. r[head_commit_id], # ours
  2701. parent_commit, # theirs
  2702. )
  2703. if conflicts:
  2704. # Update working tree with conflicts
  2705. update_working_tree(r, current_tree, merged_tree.id)
  2706. conflicted_paths = [c.decode("utf-8", "replace") for c in conflicts]
  2707. raise Error(f"Conflicts while reverting: {', '.join(conflicted_paths)}")
  2708. # Add merged tree to object store
  2709. r.object_store.add_object(merged_tree)
  2710. # Update working tree
  2711. update_working_tree(r, current_tree, merged_tree.id)
  2712. current_tree = merged_tree.id
  2713. if not no_commit:
  2714. # Create revert commit
  2715. revert_commit = Commit()
  2716. revert_commit.tree = merged_tree.id
  2717. revert_commit.parents = [head_commit_id]
  2718. # Set author/committer
  2719. if author is None:
  2720. author = get_user_identity(r.get_config_stack())
  2721. if committer is None:
  2722. committer = author
  2723. revert_commit.author = author
  2724. revert_commit.committer = committer
  2725. # Set timestamps
  2726. timestamp = int(time.time())
  2727. timezone = 0 # UTC
  2728. revert_commit.author_time = timestamp
  2729. revert_commit.author_timezone = timezone
  2730. revert_commit.commit_time = timestamp
  2731. revert_commit.commit_timezone = timezone
  2732. # Set message
  2733. if message is None:
  2734. # Extract original commit subject
  2735. original_message = commit_to_revert.message
  2736. if isinstance(original_message, bytes):
  2737. original_message = original_message.decode("utf-8", "replace")
  2738. subject = original_message.split("\n")[0]
  2739. message = f'Revert "{subject}"\n\nThis reverts commit {commit_to_revert.id.decode("ascii")}.'.encode()
  2740. elif isinstance(message, str):
  2741. message = message.encode("utf-8")
  2742. revert_commit.message = message
  2743. # Add commit to object store
  2744. r.object_store.add_object(revert_commit)
  2745. # Update HEAD
  2746. r.refs[b"HEAD"] = revert_commit.id
  2747. head_commit_id = revert_commit.id
  2748. return head_commit_id if not no_commit else None
  2749. def gc(
  2750. repo,
  2751. auto: bool = False,
  2752. aggressive: bool = False,
  2753. prune: bool = True,
  2754. grace_period: Optional[int] = 1209600, # 2 weeks default
  2755. dry_run: bool = False,
  2756. progress=None,
  2757. ):
  2758. """Run garbage collection on a repository.
  2759. Args:
  2760. repo: Path to the repository or a Repo object
  2761. auto: If True, only run gc if needed
  2762. aggressive: If True, use more aggressive settings
  2763. prune: If True, prune unreachable objects
  2764. grace_period: Grace period in seconds for pruning (default 2 weeks)
  2765. dry_run: If True, only report what would be done
  2766. progress: Optional progress callback
  2767. Returns:
  2768. GCStats object with garbage collection statistics
  2769. """
  2770. from .gc import garbage_collect
  2771. with open_repo_closing(repo) as r:
  2772. return garbage_collect(
  2773. r,
  2774. auto=auto,
  2775. aggressive=aggressive,
  2776. prune=prune,
  2777. grace_period=grace_period,
  2778. dry_run=dry_run,
  2779. progress=progress,
  2780. )
  2781. def count_objects(repo=".", verbose=False) -> CountObjectsResult:
  2782. """Count unpacked objects and their disk usage.
  2783. Args:
  2784. repo: Path to repository or repository object
  2785. verbose: Whether to return verbose information
  2786. Returns:
  2787. CountObjectsResult object with detailed statistics
  2788. """
  2789. with open_repo_closing(repo) as r:
  2790. object_store = r.object_store
  2791. # Count loose objects
  2792. loose_count = 0
  2793. loose_size = 0
  2794. for sha in object_store._iter_loose_objects():
  2795. loose_count += 1
  2796. path = object_store._get_shafile_path(sha)
  2797. try:
  2798. stat_info = os.stat(path)
  2799. # Git uses disk usage, not file size. st_blocks is always in
  2800. # 512-byte blocks per POSIX standard
  2801. if hasattr(stat_info, "st_blocks"):
  2802. # Available on Linux and macOS
  2803. loose_size += stat_info.st_blocks * 512 # type: ignore
  2804. else:
  2805. # Fallback for Windows
  2806. loose_size += stat_info.st_size
  2807. except FileNotFoundError:
  2808. # Object may have been removed between iteration and stat
  2809. pass
  2810. if not verbose:
  2811. return CountObjectsResult(count=loose_count, size=loose_size)
  2812. # Count pack information
  2813. pack_count = len(object_store.packs)
  2814. in_pack_count = 0
  2815. pack_size = 0
  2816. for pack in object_store.packs:
  2817. in_pack_count += len(pack)
  2818. # Get pack file size
  2819. pack_path = pack._data_path
  2820. try:
  2821. pack_size += os.path.getsize(pack_path)
  2822. except FileNotFoundError:
  2823. pass
  2824. # Get index file size
  2825. idx_path = pack._idx_path
  2826. try:
  2827. pack_size += os.path.getsize(idx_path)
  2828. except FileNotFoundError:
  2829. pass
  2830. return CountObjectsResult(
  2831. count=loose_count,
  2832. size=loose_size,
  2833. in_pack=in_pack_count,
  2834. packs=pack_count,
  2835. size_pack=pack_size,
  2836. )
  2837. def rebase(
  2838. repo: Union[Repo, str],
  2839. upstream: Union[bytes, str],
  2840. onto: Optional[Union[bytes, str]] = None,
  2841. branch: Optional[Union[bytes, str]] = None,
  2842. abort: bool = False,
  2843. continue_rebase: bool = False,
  2844. skip: bool = False,
  2845. ) -> list[bytes]:
  2846. """Rebase commits onto another branch.
  2847. Args:
  2848. repo: Repository to rebase in
  2849. upstream: Upstream branch/commit to rebase onto
  2850. onto: Specific commit to rebase onto (defaults to upstream)
  2851. branch: Branch to rebase (defaults to current branch)
  2852. abort: Abort an in-progress rebase
  2853. continue_rebase: Continue an in-progress rebase
  2854. skip: Skip current commit and continue rebase
  2855. Returns:
  2856. List of new commit SHAs created by rebase
  2857. Raises:
  2858. Error: If rebase fails or conflicts occur
  2859. """
  2860. from .rebase import RebaseConflict, RebaseError, Rebaser
  2861. with open_repo_closing(repo) as r:
  2862. rebaser = Rebaser(r)
  2863. if abort:
  2864. try:
  2865. rebaser.abort()
  2866. return []
  2867. except RebaseError as e:
  2868. raise Error(str(e))
  2869. if continue_rebase:
  2870. try:
  2871. result = rebaser.continue_()
  2872. if result is None:
  2873. # Rebase complete
  2874. return []
  2875. elif isinstance(result, tuple) and result[1]:
  2876. # Still have conflicts
  2877. raise Error(
  2878. f"Conflicts in: {', '.join(f.decode('utf-8', 'replace') for f in result[1])}"
  2879. )
  2880. except RebaseError as e:
  2881. raise Error(str(e))
  2882. # Convert string refs to bytes
  2883. if isinstance(upstream, str):
  2884. upstream = upstream.encode("utf-8")
  2885. if isinstance(onto, str):
  2886. onto = onto.encode("utf-8") if onto else None
  2887. if isinstance(branch, str):
  2888. branch = branch.encode("utf-8") if branch else None
  2889. try:
  2890. # Start rebase
  2891. rebaser.start(upstream, onto, branch)
  2892. # Continue rebase automatically
  2893. result = rebaser.continue_()
  2894. if result is not None:
  2895. # Conflicts
  2896. raise RebaseConflict(result[1])
  2897. # Return the SHAs of the rebased commits
  2898. return [c.id for c in rebaser._done]
  2899. except RebaseConflict as e:
  2900. raise Error(str(e))
  2901. except RebaseError as e:
  2902. raise Error(str(e))
  2903. def annotate(repo, path, committish=None):
  2904. """Annotate the history of a file.
  2905. :param repo: Path to the repository
  2906. :param path: Path to annotate
  2907. :param committish: Commit id to find path in
  2908. :return: List of ((Commit, TreeChange), line) tuples
  2909. """
  2910. if committish is None:
  2911. committish = "HEAD"
  2912. from dulwich.annotate import annotate_lines
  2913. with open_repo_closing(repo) as r:
  2914. commit_id = parse_commit(r, committish).id
  2915. # Ensure path is bytes
  2916. if isinstance(path, str):
  2917. path = path.encode()
  2918. return annotate_lines(r.object_store, commit_id, path)
  2919. blame = annotate