porcelain.py 116 KB

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