core.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707
  1. import logging
  2. import subprocess
  3. from threading import RLock
  4. from . import utils
  5. from . import exceptions as ex
  6. from pathlib import Path
  7. logger = logging.getLogger(__name__)
  8. class GitRepo:
  9. """
  10. Class to handle Git operations in a local Git repository.
  11. """
  12. __checkout_locks = dict()
  13. def __init__(self, local_path: str) -> None:
  14. """
  15. Initialize GitRepo with the path to the local Git repository.
  16. Parameters:
  17. local_path (str): Path to the Git repository
  18. """
  19. self.__set_local_path(local_path=local_path)
  20. self.__register_lock()
  21. logger.info(f"GitRepo initialised for {local_path}")
  22. def __eq__(self, other) -> bool:
  23. """
  24. Check if the instance is equal to the 'other' instance
  25. Parameters:
  26. other: the other Object to check
  27. """
  28. if type(other) is type(self):
  29. # return True if the paths of the repositories the objects
  30. # point to are equal, otherwise False
  31. return self.__local_path == other.__local_path
  32. return False
  33. def __hash__(self) -> int:
  34. """
  35. Return the hash value of the instance
  36. """
  37. return hash(self.__local_path)
  38. def __register_lock(self) -> None:
  39. """
  40. Initialize an RLock object for the instance in a shared dictionary
  41. """
  42. if not GitRepo.__checkout_locks.get(self):
  43. # create a Lock object for the instance, if is not already created
  44. GitRepo.__checkout_locks[self] = RLock()
  45. return
  46. def __set_local_path(self, local_path: str) -> None:
  47. """
  48. Set the path for the repository, ensuring it is a valid Git repo.
  49. Parameters:
  50. local_path (str): Path to the Git repository
  51. Raises:
  52. NonGitDirectoryError: If the directory is not a valid
  53. Git repository
  54. """
  55. if not utils.is_git_repo(local_path):
  56. raise ex.NonGitDirectoryError(directory=local_path)
  57. self.__local_path = local_path
  58. def get_local_path(self) -> str:
  59. """
  60. Return the local path of the repository.
  61. Returns:
  62. str: Path to the Git repository
  63. """
  64. return self.__local_path
  65. def get_checkout_lock(self) -> RLock:
  66. """
  67. Return the checkout lock object associated with the instance
  68. Returns:
  69. RLock: The lock object associated with the instance
  70. """
  71. lock = GitRepo.__checkout_locks.get(self)
  72. if lock is None:
  73. raise ex.LockNotInitializedError(
  74. lock_name="checkout_lock",
  75. path=self.__local_path
  76. )
  77. return lock
  78. def __checkout(self, commit_ref: str, force: bool = False) -> None:
  79. """
  80. Check out a specific commit.
  81. Parameters:
  82. commit_ref (str): Commit reference to check out
  83. force (bool): Force checkout (default is False)
  84. Raises:
  85. ValueError: If commit_ref is None
  86. """
  87. if commit_ref is None:
  88. raise ValueError("commit_ref is required, cannot be None.")
  89. cmd = ['git', 'checkout', commit_ref]
  90. if force:
  91. cmd.append('-f')
  92. logger.debug(f"Running {' '.join(cmd)}")
  93. logger.debug("Attempting to aquire checkout lock.")
  94. with self.get_checkout_lock():
  95. subprocess.run(cmd, cwd=self.__local_path, shell=False, check=True)
  96. def __reset(self, commit_ref: str, hard: bool = False) -> None:
  97. """
  98. Reset to a specific commit.
  99. Parameters:
  100. commit_ref (str): Commit reference to reset to
  101. hard (bool): Use hard reset (default is False)
  102. Raises:
  103. ValueError: If commit_ref is None
  104. """
  105. if commit_ref is None:
  106. raise ValueError("commit_ref is required, cannot be None.")
  107. cmd = ['git', 'reset', commit_ref]
  108. if hard:
  109. cmd.append('--hard')
  110. logger.debug(f"Running {' '.join(cmd)}")
  111. subprocess.run(cmd, cwd=self.__local_path, shell=False, check=True)
  112. def __force_recursive_clean(self) -> None:
  113. """
  114. Forcefully clean the working directory,
  115. removing untracked files and directories.
  116. """
  117. cmd = ['git', 'clean', '-xdff']
  118. logger.debug(f"Running {' '.join(cmd)}")
  119. subprocess.run(cmd, cwd=self.__local_path, shell=False, check=True)
  120. def __remote_list(self) -> list[str]:
  121. """
  122. Retrieve a list of remotes added to the repository
  123. Returns:
  124. list[str]: List of remote names
  125. """
  126. cmd = ['git', 'remote']
  127. logger.debug(f"Running {' '.join(cmd)}")
  128. ret = subprocess.run(
  129. cmd, cwd=self.__local_path, shell=False, capture_output=True,
  130. encoding='utf-8', check=True
  131. )
  132. return ret.stdout.split('\n')[:-1]
  133. def __is_commit_present_locally(self, commit_ref: str) -> bool:
  134. """
  135. Check if a specific commit exists locally.
  136. Parameters:
  137. commit_ref (str): Commit hash to check
  138. Returns:
  139. bool: True if the commit exists locally, False otherwise
  140. """
  141. if commit_ref is None:
  142. raise ValueError("commit_ref is required, cannot be None.")
  143. cmd = ['git', 'diff-tree', commit_ref, '--no-commit-id', '--no-patch']
  144. logger.debug(f"Running {' '.join(cmd)}")
  145. ret = subprocess.run(cmd, cwd=self.__local_path, shell=False)
  146. return ret.returncode == 0
  147. def remote_set_url(self, remote: str, url: str) -> None:
  148. """
  149. Set the URL for a specific remote.
  150. Parameters:
  151. remote (str): Name of the remote
  152. url (str): URL to set for the remote
  153. Raises:
  154. ValueError: If remote or URL is None
  155. """
  156. if remote is None:
  157. raise ValueError("remote is required, cannot be None.")
  158. if url is None:
  159. raise ValueError("url is required, cannot be None.")
  160. cmd = ['git', 'remote', 'set-url', remote, url]
  161. logger.debug(f"Running {' '.join(cmd)}")
  162. subprocess.run(cmd, cwd=self.__local_path, check=True)
  163. def remote_get_url(self, remote: str) -> str:
  164. """
  165. Get the URL for a specific remote.
  166. Parameters:
  167. remote (str): Name of the remote
  168. Returns:
  169. str: The URL associated with the remote
  170. Raises:
  171. ValueError: If remote is None
  172. """
  173. if remote is None:
  174. raise ValueError("remote is required, cannot be None.")
  175. cmd = ['git', 'remote', 'get-url', remote]
  176. logger.debug(f"Running {' '.join(cmd)}")
  177. # Capture the output of the command
  178. result = subprocess.run(
  179. cmd,
  180. cwd=self.__local_path,
  181. check=True,
  182. capture_output=True,
  183. text=True
  184. )
  185. # Return the URL from the output
  186. return result.stdout.strip()
  187. def fetch_remote(self, remote: str, force: bool = False,
  188. tags: bool = False, recurse_submodules: bool = False,
  189. refetch: bool = False) -> None:
  190. """
  191. Fetch updates from a remote repository.
  192. Parameters:
  193. remote (str): Remote to fetch from; if None, fetches all
  194. force (bool): Force fetch (default is False)
  195. tags (bool): Fetch tags (default is False)
  196. recurse_submodules (bool): Recurse into submodules
  197. (default is False)
  198. refetch (bool): Re-fetch all objects (default is False)
  199. """
  200. cmd = ['git', 'fetch']
  201. if remote:
  202. cmd.append(remote)
  203. else:
  204. logger.info("fetch_remote: remote is None, fetching all remotes")
  205. cmd.append('--all')
  206. if force:
  207. cmd.append('--force')
  208. if tags:
  209. cmd.append('--tags')
  210. if refetch:
  211. cmd.append('--refetch')
  212. if recurse_submodules:
  213. cmd.append('--recurse-submodules')
  214. else:
  215. cmd.append('--no-recurse-submodules')
  216. logger.debug(f"Running {' '.join(cmd)}")
  217. subprocess.run(cmd, cwd=self.__local_path, shell=False)
  218. def __branch_create(self, branch_name: str,
  219. start_point: str = None,
  220. force: bool = False) -> None:
  221. """
  222. Create a new branch starting from a given commit.
  223. Parameters:
  224. branch_name (str): Name of the branch to create
  225. start_point (str): Starting commit or branch (optional)
  226. force (bool): Force creation, resetting the branch tip if it
  227. already exists (default is False)
  228. """
  229. if branch_name is None:
  230. raise ValueError("branch_name is required, cannot be None.")
  231. cmd = ['git', 'branch', branch_name]
  232. if force:
  233. cmd.append('-f')
  234. if start_point:
  235. if not self.__is_commit_present_locally(commit_ref=start_point):
  236. raise ex.CommitNotFoundError(commit_ref=start_point)
  237. cmd.append(start_point)
  238. logger.debug(f"Running {' '.join(cmd)}")
  239. subprocess.run(cmd, cwd=self.__local_path, shell=False, check=True)
  240. def __branch_delete(self, branch_name: str, force: bool = False) -> None:
  241. """
  242. Delete a local branch.
  243. Parameters:
  244. branch_name (str): Name of the branch to delete
  245. force (bool): Force delete (default is False)
  246. """
  247. if branch_name is None:
  248. raise ValueError("branch_name is required, cannot be None.")
  249. if not self.__is_commit_present_locally(commit_ref=branch_name):
  250. raise ex.CommitNotFoundError(commit_ref=branch_name)
  251. cmd = ['git', 'branch', '-d', branch_name]
  252. if force:
  253. cmd.append('--force')
  254. logger.debug(f"Running {' '.join(cmd)}")
  255. subprocess.run(cmd, cwd=self.__local_path, shell=False, check=True)
  256. def commit_id_for_remote_ref(self, remote: str,
  257. commit_ref: str) -> str:
  258. """
  259. Get the commit ID for a specific commit reference from a remote.
  260. Parameters:
  261. remote (str): Name of the remote
  262. commit_ref (str): Reference to get the commit ID for
  263. Returns:
  264. str | None: Commit ID if found, None otherwise
  265. """
  266. if remote is None:
  267. raise ValueError("remote is required, cannot be None.")
  268. if remote not in self.__remote_list():
  269. raise ex.RemoteNotFoundError(remote=remote)
  270. if commit_ref is None:
  271. raise ValueError("commit_ref is required, cannot be None.")
  272. if utils.is_valid_hex_string(test_str=commit_ref):
  273. # skip conversion if commit_ref is already hex string
  274. return commit_ref
  275. # allow branches and tags only for now
  276. allowed_ref_types = ['tags', 'heads']
  277. split_ref = commit_ref.split('/', 2)
  278. if len(split_ref) != 3 or split_ref[0] != 'refs':
  279. raise ValueError(f"commit_ref '{commit_ref}' format is invalid.")
  280. _, ref_type, _ = split_ref
  281. if ref_type not in allowed_ref_types:
  282. raise ValueError(f"ref_type '{ref_type}' is not supported.")
  283. cmd = ['git', 'ls-remote', remote]
  284. logger.debug(f"Running {' '.join(cmd)}")
  285. ret = subprocess.run(
  286. cmd, cwd=self.__local_path, encoding='utf-8', capture_output=True,
  287. shell=False, check=True
  288. )
  289. for line in ret.stdout.split('\n')[:-1]:
  290. (commit_id, res_ref) = line.split('\t')
  291. if res_ref == commit_ref:
  292. return commit_id
  293. return None
  294. def __ensure_commit_fetched(self, remote: str, commit_id: str) -> None:
  295. """
  296. Ensure a specific commit is fetched from the remote repository.
  297. Parameters:
  298. remote (str): Remote name to fetch from
  299. commit_id (str): Commit ID to ensure it is available locally
  300. Raises:
  301. RemoteNotFoundError: If the specified remote does not exist
  302. CommitNotFoundError: If the commit cannot be fetched after
  303. multiple attempts
  304. """
  305. if remote is None:
  306. raise ValueError("remote is required, cannot be None.")
  307. if remote not in self.__remote_list():
  308. raise ex.RemoteNotFoundError(remote=remote)
  309. if commit_id is None:
  310. raise ValueError("commit_id is required, cannot be None.")
  311. if not utils.is_valid_hex_string(test_str=commit_id):
  312. raise ValueError(
  313. f"commit_id should be a hex string, got '{commit_id}'."
  314. )
  315. if self.__is_commit_present_locally(commit_ref=commit_id):
  316. # early return if commit is already fetched
  317. return
  318. self.fetch_remote(remote=remote, force=True, tags=True)
  319. # retry fetch with refetch option if the commit is still not found
  320. if not self.__is_commit_present_locally(commit_ref=commit_id):
  321. self.fetch_remote(
  322. remote=remote, force=True, tags=True, refetch=True
  323. )
  324. if not self.__is_commit_present_locally(commit_ref=commit_id):
  325. raise ex.CommitNotFoundError(commit_ref=commit_id)
  326. def checkout_remote_commit_ref(self, remote: str,
  327. commit_ref: str,
  328. force: bool = False,
  329. hard_reset: bool = False,
  330. clean_working_tree: bool = False) -> None:
  331. """
  332. Check out a specific commit from a remote repository.
  333. Parameters:
  334. remote (str): Remote name to check out from
  335. commit_ref (str): Commit reference to check out
  336. force (bool): Force the checkout (default is False)
  337. hard_reset (bool): Hard reset after checkout (default is False)
  338. clean_working_tree (bool): Clean untracked files after checkout
  339. (default is False)
  340. Raises:
  341. RemoteNotFoundError: If the specified remote does not exist
  342. CommitNotFoundError: If the specified commit cannot be found
  343. """
  344. if remote is None:
  345. logger.error("remote cannot be None for checkout to remote commit")
  346. raise ValueError("remote is required, cannot be None.")
  347. if remote not in self.__remote_list():
  348. raise ex.RemoteNotFoundError(remote=remote)
  349. if commit_ref is None:
  350. raise ValueError("commit_ref is required, cannot be None.")
  351. # retrieve the commit ID for the specified commit reference
  352. commit_id = self.commit_id_for_remote_ref(
  353. remote=remote, commit_ref=commit_ref
  354. )
  355. # ensure the commit is fetched from the remote repository
  356. self.__ensure_commit_fetched(remote=remote, commit_id=commit_id)
  357. # perform checkout on the specified commit using the commit ID
  358. # commit ID is used in place of branch name or tag name to make sure
  359. # do not check out the branch or tag from wrong remote
  360. self.__checkout(commit_ref=commit_id, force=force)
  361. # optional hard reset and clean of working tree after checkout
  362. if hard_reset:
  363. self.__reset(commit_ref=commit_id, hard=True)
  364. if clean_working_tree:
  365. self.__force_recursive_clean()
  366. def submodule_update(self, init: bool = False, recursive: bool = False,
  367. force: bool = False) -> None:
  368. """
  369. Update Git submodules for the repository.
  370. Parameters:
  371. init (bool): Initialize submodules if they are not initialized
  372. (default is False)
  373. recursive (bool): Update submodules recursively (default is False)
  374. force (bool): Force update even if there are changes
  375. (default is False)
  376. """
  377. cmd = ['git', 'submodule', 'update']
  378. if init:
  379. cmd.append('--init')
  380. if recursive:
  381. cmd.append('--recursive')
  382. if force:
  383. cmd.append('--force')
  384. logger.debug(f"Running {' '.join(cmd)}")
  385. subprocess.run(cmd, cwd=self.__local_path, shell=False, check=True)
  386. def remote_add(self, remote: str, url: str) -> None:
  387. """
  388. Add a new remote to the Git repository.
  389. Parameters:
  390. remote (str): Name of the remote to add
  391. url (str): URL for the remote repository
  392. Raises:
  393. DuplicateRemoteError: If remote already exists and
  394. overwrite is not allowed
  395. """
  396. if remote is None:
  397. raise ValueError("remote is required, cannot be None.")
  398. if url is None:
  399. raise ValueError("url is required, cannot be None.")
  400. # Set the URL if the remote exists and overwrite is allowed
  401. if remote in self.__remote_list():
  402. raise ex.DuplicateRemoteError(remote)
  403. # Add the new remote
  404. cmd = ['git', 'remote', 'add', remote, url]
  405. logger.debug(f"Running {' '.join(cmd)}")
  406. subprocess.run(cmd, cwd=self.__local_path, shell=False, check=True)
  407. def remote_add_bulk(self, remotes: tuple, force: bool = False) -> None:
  408. """
  409. Add multiple remotes to the Git repository at once.
  410. Parameters:
  411. remotes (tuple): Tuple of tuples containing remote name
  412. and url.
  413. E.g. (
  414. ('remote1', 'https://remote1_url'),
  415. ('remote2', 'https://remote2_url'),
  416. )
  417. force (bool): Force update the url if remote already exists.
  418. Raises:
  419. DuplicateRemoteError: If remote already exists and
  420. overwrite is not allowed.
  421. """
  422. logger.info(f"Remotes to add: {remotes}.")
  423. for (remote, url) in remotes:
  424. try:
  425. self.remote_add(remote=remote, url=url)
  426. except ex.DuplicateRemoteError:
  427. if not force:
  428. raise
  429. logger.info(f"Remote {remote} already exists. Updating url.")
  430. self.remote_set_url(remote=remote, url=url)
  431. logger.info(f"Remote {remote} added to repo with url {url}.")
  432. @staticmethod
  433. def clone(source: str,
  434. dest: str,
  435. branch: str = None,
  436. single_branch: bool = False,
  437. recurse_submodules: bool = False,
  438. shallow_submodules: bool = False) -> "GitRepo":
  439. """
  440. Clone a Git repository.
  441. Parameters:
  442. source (str): Source path of the repository to clone
  443. Can be local or a url.
  444. dest (str): Destination path for the clone
  445. branch (str): Specific branch to clone (optional)
  446. single_branch (bool): Only clone a single branch (default is False)
  447. recurse_submodules (bool): Recurse into submodules
  448. (default is False)
  449. shallow_submodules (bool): any cloned submodules will be shallow
  450. Returns:
  451. GitRepo: the cloned git repository
  452. """
  453. cmd = ['git', 'clone', source, dest]
  454. if branch:
  455. cmd.append('--branch=' + branch)
  456. if single_branch:
  457. cmd.append('--single-branch')
  458. if recurse_submodules:
  459. cmd.append('--recurse-submodules')
  460. if shallow_submodules:
  461. cmd.append('--shallow-submodules')
  462. logger.debug(f"Running {' '.join(cmd)}")
  463. subprocess.run(cmd, shell=False, check=True)
  464. return GitRepo(local_path=dest)
  465. @staticmethod
  466. def shallow_clone_at_commit_from_local(source: str,
  467. remote: str,
  468. commit_ref: str,
  469. dest: str) -> "GitRepo":
  470. """
  471. Perform a shallow clone of a repository at a specific commit.
  472. Parameters:
  473. source (str): Source path of the local repository
  474. remote (str): Remote name containing the commit
  475. commit_ref (str): Commit reference to clone
  476. dest (str): Destination path for the clone
  477. Returns:
  478. GitRepo: the cloned git repository
  479. Raises:
  480. RemoteNotFoundError: If the specified remote does not exist
  481. CommitNotFoundError: If the specified commit cannot be found
  482. """
  483. if remote is None:
  484. raise ValueError("remote is required, cannot be None.")
  485. if commit_ref is None:
  486. raise ValueError("commit_ref is required, cannot be None.")
  487. source_repo = GitRepo(local_path=source)
  488. # get the commit ID for the specified remote reference
  489. commit_id = source_repo.commit_id_for_remote_ref(
  490. remote=remote, commit_ref=commit_ref
  491. )
  492. source_repo.__ensure_commit_fetched(remote=remote, commit_id=commit_id)
  493. # create a temporary branch to point to the specified commit
  494. # as shallow clone needs a branch
  495. temp_branch_name = "temp-b-" + commit_id
  496. source_repo.__branch_create(
  497. branch_name=temp_branch_name, start_point=commit_id, force=True
  498. )
  499. # perform the clone from the source repository
  500. # using the temporary branch
  501. cloned_repo = GitRepo.clone(
  502. source=source,
  503. dest=dest,
  504. branch=temp_branch_name,
  505. single_branch=True,
  506. recurse_submodules=True,
  507. shallow_submodules=True
  508. )
  509. # add the remote containing the commit in cloned repo for reference
  510. url = source_repo.remote_get_url(remote=remote)
  511. cloned_repo.remote_add(remote=remote, url=url)
  512. # delete the temporary branch in source repository
  513. # after the clone operation
  514. source_repo.__branch_delete(branch_name=temp_branch_name, force=True)
  515. return cloned_repo
  516. @staticmethod
  517. def clone_if_needed(source: str,
  518. dest: str,
  519. branch: str = None,
  520. single_branch: bool = False,
  521. recurse_submodules: bool = False,
  522. shallow_submodules: bool = False) -> "GitRepo":
  523. """
  524. Clone from the given source if a git repository does not exist.
  525. Parameters:
  526. source (str): Source path of the repository to clone
  527. Can be local or a url.
  528. dest (str): Destination path for the clone
  529. branch (str): Specific branch to clone (optional)
  530. single_branch (bool): Only clone a single branch (default is False)
  531. recurse_submodules (bool): Recurse into submodules (default is
  532. False)
  533. shallow_submodules (bool): Any cloned submodules will be shallow
  534. Returns:
  535. GitRepo: the GitRepo object for the local git repository.
  536. Raises:
  537. NonGitDirectoryError: If a directory exists at `dest` but is not
  538. a git repository.
  539. """
  540. repo: GitRepo
  541. try:
  542. repo = GitRepo(
  543. local_path=dest,
  544. )
  545. except FileNotFoundError:
  546. logger.info(f"No git repo found at {dest}. Creating.")
  547. # create parent directory if not present
  548. p = Path(dest)
  549. Path.mkdir(p.parent, parents=True, exist_ok=True)
  550. # clone
  551. repo = GitRepo.clone(
  552. source=source,
  553. dest=dest,
  554. branch=branch,
  555. single_branch=single_branch,
  556. recurse_submodules=recurse_submodules,
  557. shallow_submodules=shallow_submodules,
  558. )
  559. return repo