porcelain.py 91 KB

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