scm.py 45 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205
  1. # Copyright (c) 2012 The Chromium Authors. All rights reserved.
  2. # Use of this source code is governed by a BSD-style license that can be
  3. # found in the LICENSE file.
  4. """SCM-specific utility classes."""
  5. from __future__ import annotations
  6. import abc
  7. import contextlib
  8. import os
  9. import pathlib
  10. import platform
  11. import re
  12. import threading
  13. from collections import defaultdict
  14. from itertools import chain
  15. from typing import Any
  16. from typing import Collection, Iterable, Iterator, Literal, Dict
  17. from typing import Optional, Sequence, Mapping
  18. import gclient_utils
  19. import git_common
  20. import subprocess2
  21. # TODO: Should fix these warnings.
  22. # pylint: disable=line-too-long
  23. # constants used to identify the tree state of a directory.
  24. VERSIONED_NO = 0
  25. VERSIONED_DIR = 1
  26. VERSIONED_SUBMODULE = 2
  27. def determine_scm(root):
  28. """Similar to upload.py's version but much simpler.
  29. Returns 'git' or 'diff'.
  30. """
  31. if os.path.isdir(os.path.join(root, '.git')):
  32. return 'git'
  33. try:
  34. subprocess2.check_call(['git', 'rev-parse', '--show-cdup'],
  35. stdout=subprocess2.DEVNULL,
  36. stderr=subprocess2.DEVNULL,
  37. cwd=root)
  38. return 'git'
  39. except (OSError, subprocess2.CalledProcessError):
  40. return 'diff'
  41. GitConfigScope = Literal['system', 'global', 'local', 'worktree']
  42. GitScopeOrder: list[GitConfigScope] = ['system', 'global', 'local', 'worktree']
  43. GitFlatConfigData = Mapping[str, Mapping[str, Sequence[str]]]
  44. class GitConfigStateBase(metaclass=abc.ABCMeta):
  45. """GitConfigStateBase is the abstract base class for implementations of
  46. CachedGitConfigState.
  47. This is meant to model the manipulation of some underlying config data.
  48. In GitConfigStateReal, this is modeled using `git config` commands in
  49. a specific git repo.
  50. In GitConfigStateTest, this is modeled using a set of GitConfigScope-indexed
  51. dictionaries.
  52. Implementations MUST ensure that all keys returned in load_config are
  53. already canonicalized, and implementations MUST accept non-canonical keys to
  54. set_* and unset_* methods.
  55. """
  56. @abc.abstractmethod
  57. def load_config(self) -> GitFlatConfigData:
  58. """When invoked, this should return the full state of the configuration
  59. observable.
  60. The caller must not mutate the returned value.
  61. Implementations MUST ensure that all keys returned in load_config are
  62. already canonicalized.
  63. """
  64. @abc.abstractmethod
  65. def set_config(self, key: str, value: str, *, append: bool,
  66. scope: GitConfigScope):
  67. """When invoked, this should set `key` to a singluar `value` in the git
  68. scope `scope` in this state's underlying data.
  69. If `append` is True, this should add an additional value to the existing
  70. `key`, if any.
  71. Implementations MUST accept non-canonical `key` values.
  72. """
  73. @abc.abstractmethod
  74. def set_config_multi(self, key: str, value: str, *,
  75. value_pattern: Optional[str], scope: GitConfigScope):
  76. """When invoked, this should replace all existing values of `key` with
  77. `value` in the git scope `scope` in this state's underlying data.
  78. If `value_pattern` is supplied, only existing values matching this
  79. pattern will be replaced.
  80. TODO: Make value_pattern an re.Pattern. This wasn't done at the time of
  81. this refactor to keep the refactor small.
  82. Implementations MUST accept non-canonical `key` values.
  83. """
  84. @abc.abstractmethod
  85. def unset_config(self, key: str, *, scope: GitConfigScope,
  86. missing_ok: bool):
  87. """When invoked, remove a singlar value from `key` in this state's underlying data.
  88. If missing_ok is False and `key` is not present in the given scope, this
  89. must raise GitConfigUnsetMissingValue with `key` and `scope`.
  90. If `key` is multi-valued in this scope, this must raise
  91. GitConfigUnsetMultipleValues with `key` and `scope`.
  92. Implementations MUST accept non-canonical `key` values.
  93. """
  94. @abc.abstractmethod
  95. def unset_config_multi(self, key: str, *, value_pattern: Optional[str],
  96. scope: GitConfigScope, missing_ok: bool):
  97. """When invoked, remove all values from `key` in this state's underlying data.
  98. If `value_pattern` is supplied, only values matching this pattern will
  99. be removed.
  100. If missing_ok is False and `key` is not present in the given scope, this
  101. must raise GitConfigUnsetMissingValue with `key` and `scope`.
  102. TODO: Make value_pattern an re.Pattern. This wasn't done at the time of
  103. this refactor to keep the refactor small.
  104. Implementations MUST accept non-canonical `key` values.
  105. """
  106. class GitConfigUnsetMissingValue(ValueError):
  107. def __init__(self, key: str, scope: str) -> None:
  108. super().__init__(
  109. f'Cannot unset missing key {key!r} in scope {scope!r} with missing_ok=False.'
  110. )
  111. class GitConfigUnsetMultipleValues(ValueError):
  112. def __init__(self, key: str, scope: str) -> None:
  113. super().__init__(
  114. f'Cannot unset multi-value key {key!r} in scope {scope!r} with modify_all=False.'
  115. )
  116. class GitConfigUneditableScope(ValueError):
  117. def __init__(self, scope: str) -> None:
  118. super().__init__(f'Cannot edit git config in scope {scope!r}.')
  119. class GitConfigUnknownScope(ValueError):
  120. def __init__(self, scope: str) -> None:
  121. super().__init__(f'Unknown git config scope {scope!r}.')
  122. class GitConfigInvalidKey(ValueError):
  123. def __init__(self, key: str) -> None:
  124. super().__init__(
  125. f'Invalid git config key {key!r}: does not contain a section.')
  126. def canonicalize_git_config_key(key: str) -> str:
  127. """Returns the canonicalized form of `key` for git config.
  128. Git config internally canonicalizes keys (i.e. for
  129. 'section.subsection.variable', both 'section' and 'variable' will be
  130. lowercased, but 'subsection' will not).
  131. This also normalizes keys in the form 'section.variable' (both 'section' and
  132. 'variable' will be lowercased).
  133. """
  134. sections = key.split('.')
  135. if len(sections) >= 3:
  136. return '.'.join(
  137. chain((sections[0].lower(), ), sections[1:-1],
  138. (sections[-1].lower(), )))
  139. if len(sections) == 2:
  140. return '.'.join((sections[0].lower(), sections[1].lower()))
  141. raise GitConfigInvalidKey(key)
  142. class CachedGitConfigState(object):
  143. """This represents the observable git configuration state for a given
  144. repository (whose top-level path is `root`).
  145. This maintains an in-memory cache of the entire, flattened, observable
  146. configuration state according to the GitConfigStateBase implementation.
  147. All SetConfig operations which actually change the underlying data will
  148. clear the internal cache. All read operations will either use the internal
  149. cache, or repopulate it from the GitConfigStateBase implementation
  150. on-demand.
  151. This design assumes no other processes are mutating git config state, which
  152. is typically true for git_cl and other short-lived programs in depot_tools
  153. which use scm.py.
  154. """
  155. def __init__(self, impl: GitConfigStateBase):
  156. """Initializes a git config cache against the given underlying
  157. GitConfigStateBase (either GitConfigStateReal or GitConfigStateTest).
  158. """
  159. self._impl: GitConfigStateBase = impl
  160. # Actual cached configuration from the point of view of this root.
  161. self._config: Optional[GitFlatConfigData] = None
  162. def _maybe_load_config(self) -> GitFlatConfigData:
  163. if self._config is None:
  164. # NOTE: Implementations of self._impl must already ensure that all
  165. # keys are canonicalized.
  166. self._config = self._impl.load_config()
  167. return self._config
  168. def clear_cache(self):
  169. self._config = None
  170. def GetConfig(self,
  171. key: str,
  172. default: Optional[str] = None,
  173. scope: Optional[str] = None) -> Optional[str]:
  174. """Lazily loads all configration observable for this CachedGitConfigState,
  175. then returns the last value for `key` as a string.
  176. If `key` is missing, returns default.
  177. """
  178. key = canonicalize_git_config_key(key)
  179. if not scope:
  180. scope = "default"
  181. scoped_config = self._maybe_load_config()
  182. if not scoped_config:
  183. return default
  184. scoped_config = scoped_config.get(scope, None)
  185. if not scoped_config:
  186. return default
  187. values = scoped_config.get(key, None)
  188. if not values:
  189. return default
  190. return values[-1]
  191. def GetConfigBool(self, key: str) -> bool:
  192. """Returns the booleanized value of `key`.
  193. This follows `git config` semantics (i.e. it normalizes the string value
  194. of the config value to "true" - all other string values return False).
  195. """
  196. return self.GetConfig(key) == 'true'
  197. def GetConfigList(self, key: str) -> list[str]:
  198. """Returns all values of `key` as a list of strings."""
  199. key = canonicalize_git_config_key(key)
  200. return list(self._maybe_load_config().get('default', {}).get(key, ()))
  201. def YieldConfigRegexp(self,
  202. pattern: Optional[str] = None
  203. ) -> Iterable[tuple[str, str]]:
  204. """Yields (key, value) pairs for any config keys matching `pattern`.
  205. This use re.match, so `pattern` needs to be for the entire config key.
  206. If `pattern` is None, this returns all config items.
  207. Note that `pattern` is always matched against the canonicalized key
  208. value (i.e. for 'section.[subsection.]variable', both 'section' and
  209. 'variable' will be lowercased, but 'subsection', if present, will not).
  210. """
  211. if pattern is None:
  212. pred = lambda _: True
  213. else:
  214. pred = re.compile(pattern).match
  215. for key, values in sorted(self._maybe_load_config().get('default',
  216. {}).items()):
  217. if pred(key):
  218. for value in values:
  219. yield key, value
  220. def SetConfig(self,
  221. key,
  222. value=None,
  223. *,
  224. append: bool = False,
  225. missing_ok: bool = True,
  226. modify_all: bool = False,
  227. scope: GitConfigScope = 'local',
  228. value_pattern: Optional[str] = None):
  229. """Sets or unsets one or more config values.
  230. Args:
  231. cwd: path to set `git config` for.
  232. key: The specific config key to affect.
  233. value: The value to set. If this is None, `key` will be unset.
  234. append: If True and `value` is not None, this will append
  235. the value instead of replacing an existing one. Must not be
  236. specified with value_pattern.
  237. missing_ok: If `value` is None (i.e. this is an unset operation),
  238. ignore retcode=5 from `git config` (meaning that the value is
  239. not present). If `value` is not None, then this option has no
  240. effect. If this is false and the key is missing, this will raise
  241. GitConfigUnsetMissingValue.
  242. modify_all: If True, this will change a set operation to
  243. `--replace-all`, and will change an unset operation to
  244. `--unset-all`. Must not be specified with value_pattern.
  245. scope: By default this is the `local` scope, but could be `global`
  246. or `worktree`, depending on which config scope you want to affect.
  247. Note that the `system` scope cannot be modified.
  248. value_pattern: For use with `modify_all=True`, allows
  249. further filtering of the set or unset operation based on
  250. the currently configured value. Ignored for
  251. `modify_all=False`.
  252. """
  253. if scope not in GitScopeOrder:
  254. raise GitConfigUnknownScope(scope)
  255. if scope == 'system':
  256. raise GitConfigUneditableScope(scope)
  257. if value is None:
  258. if modify_all:
  259. self._impl.unset_config_multi(key,
  260. value_pattern=value_pattern,
  261. scope=scope,
  262. missing_ok=missing_ok)
  263. else:
  264. self._impl.unset_config(key, scope=scope, missing_ok=missing_ok)
  265. else:
  266. if value_pattern:
  267. if not modify_all:
  268. raise ValueError(
  269. 'SetConfig with (value_pattern) and (not modify_all) is invalid.'
  270. )
  271. if append:
  272. raise ValueError(
  273. 'SetConfig with (value_pattern) and (append) is invalid.'
  274. )
  275. self._impl.set_config_multi(key,
  276. value,
  277. value_pattern=value_pattern,
  278. scope=scope)
  279. else:
  280. if modify_all:
  281. self._impl.set_config_multi(key,
  282. value,
  283. value_pattern=None,
  284. scope=scope)
  285. self._impl.set_config(key, value, append=append, scope=scope)
  286. # Once the underlying storage has set the value, we clear our cache so
  287. # the next getter will reload it.
  288. self.clear_cache()
  289. class GitConfigStateReal(GitConfigStateBase):
  290. """GitConfigStateReal implements CachedGitConfigState by actually interacting with
  291. the git configuration files on disk via GIT.Capture.
  292. """
  293. _GLOBAL_LOCK = threading.Lock()
  294. def __init__(self, root: pathlib.Path):
  295. super().__init__()
  296. self.root = root
  297. def load_config(self) -> GitFlatConfigData:
  298. # NOTE: `git config --list` already canonicalizes keys.
  299. try:
  300. gitConfigCmd = ['config', '--list', '-z', '--show-scope']
  301. if git_common.get_git_version() <= (2, 25):
  302. gitConfigCmd = ['config', '--list', '-z']
  303. rawConfig = GIT.Capture(gitConfigCmd,
  304. cwd=self.root,
  305. strip_out=False)
  306. except subprocess2.CalledProcessError:
  307. return {}
  308. assert isinstance(rawConfig, str)
  309. cfg: Dict[str, Dict[str,
  310. list[str]]] = defaultdict(lambda: defaultdict(list))
  311. entries = rawConfig.split('\x00')[:-1]
  312. def process_entry(entry: str, scope: str) -> None:
  313. parts = entry.split('\n', 1)
  314. key, value = parts if len(parts) == 2 else (parts[0], '')
  315. key, value = key.strip(), value.strip()
  316. cfg[scope][key].append(value)
  317. if scope != "default":
  318. cfg["default"][key].append(value)
  319. i = 0
  320. while i < len(entries):
  321. if entries[i] in ['local', 'global', 'system']:
  322. scope = entries[i]
  323. i += 1
  324. if i < len(entries):
  325. process_entry(entries[i], scope)
  326. else:
  327. process_entry(entries[i], "default")
  328. i += 1
  329. return cfg
  330. def set_config(self, key: str, value: str, *, append: bool,
  331. scope: GitConfigScope):
  332. # NOTE: `git config` already canonicalizes key.
  333. args = ['config', f'--{scope}', key, value]
  334. if append:
  335. args.append('--add')
  336. with self._scope_lock(scope):
  337. GIT.Capture(args, cwd=self.root)
  338. def set_config_multi(self, key: str, value: str, *,
  339. value_pattern: Optional[str], scope: GitConfigScope):
  340. # NOTE: `git config` already canonicalizes key.
  341. args = ['config', f'--{scope}', '--replace-all', key, value]
  342. if value_pattern is not None:
  343. args.append(value_pattern)
  344. with self._scope_lock(scope):
  345. GIT.Capture(args, cwd=self.root)
  346. def unset_config(self, key: str, *, scope: GitConfigScope,
  347. missing_ok: bool):
  348. # NOTE: `git config` already canonicalizes key.
  349. accepted_retcodes = (0, 5) if missing_ok else (0, )
  350. try:
  351. with self._scope_lock(scope):
  352. GIT.Capture(['config', f'--{scope}', '--unset', key],
  353. cwd=self.root,
  354. accepted_retcodes=accepted_retcodes)
  355. except subprocess2.CalledProcessError as cpe:
  356. if cpe.returncode == 5:
  357. if b'multiple values' in cpe.stderr:
  358. raise GitConfigUnsetMultipleValues(key, scope)
  359. raise GitConfigUnsetMissingValue(key, scope)
  360. raise
  361. def unset_config_multi(self, key: str, *, value_pattern: Optional[str],
  362. scope: GitConfigScope, missing_ok: bool):
  363. # NOTE: `git config` already canonicalizes key.
  364. accepted_retcodes = (0, 5) if missing_ok else (0, )
  365. args = ['config', f'--{scope}', '--unset-all', key]
  366. if value_pattern is not None:
  367. args.append(value_pattern)
  368. try:
  369. with self._scope_lock(scope):
  370. GIT.Capture(args,
  371. cwd=self.root,
  372. accepted_retcodes=accepted_retcodes)
  373. except subprocess2.CalledProcessError as cpe:
  374. if cpe.returncode == 5:
  375. raise GitConfigUnsetMissingValue(key, scope)
  376. raise
  377. def _scope_lock(
  378. self,
  379. scope: GitConfigScope) -> contextlib.AbstractContextManager[Any]:
  380. if scope == 'global':
  381. return self._GLOBAL_LOCK
  382. # TODO(ayatane): We should lock per local repo scope as well
  383. # from a correctness perspective.
  384. return contextlib.nullcontext()
  385. class GitConfigStateTest(GitConfigStateBase):
  386. """A fake implementation of GitConfigStateBase for testing.
  387. To properly initialize this, see tests/scm_mock.py.
  388. """
  389. def __init__(self,
  390. global_state_lock: threading.Lock,
  391. global_state: dict[str, list[str]],
  392. *,
  393. system_state: Optional[GitFlatConfigData] = None):
  394. """Initializes a new (local, worktree) config state, with a reference to
  395. a single global `global` state and an optional immutable `system` state.
  396. All keys in global_state and system_state MUST already be canonicalized
  397. with canonicalize_key().
  398. The caller must supply a single shared Lock, plus a mutable reference to
  399. the global-state dictionary.
  400. This implementation will hold global_state_lock during all read/write
  401. operations on the 'global' scope.
  402. """
  403. self.system_state: GitFlatConfigData = system_state or {}
  404. self.global_state_lock = global_state_lock
  405. self.global_state = global_state
  406. self.worktree_state: dict[str, list[str]] = {}
  407. self.local_state: dict[str, list[str]] = {}
  408. super().__init__()
  409. @contextlib.contextmanager
  410. def _editable_scope(
  411. self, scope: GitConfigScope) -> Iterator[dict[str, list[str]]]:
  412. if scope == 'system':
  413. # This is also checked in CachedGitConfigState.SetConfig, but double
  414. # check here.
  415. raise GitConfigUneditableScope(scope)
  416. if scope == 'global':
  417. with self.global_state_lock:
  418. yield self.global_state
  419. elif scope == 'local':
  420. yield self.local_state
  421. elif scope == 'worktree':
  422. yield self.worktree_state
  423. else:
  424. # This is also checked in CachedGitConfigState.SetConfig, but double
  425. # check here.
  426. raise GitConfigUnknownScope(scope)
  427. def load_config(self) -> GitFlatConfigData:
  428. cfg: Mapping[str, Mapping[str, list[str]]] = defaultdict(
  429. lambda: defaultdict(list))
  430. for key, values in self.system_state.items():
  431. cfg['system'][key].extend(values)
  432. cfg['default'][key].extend(values)
  433. for ordered_scope in GitScopeOrder:
  434. if ordered_scope == 'system':
  435. continue
  436. with self._editable_scope(ordered_scope) as scope_cfg:
  437. for key, values in scope_cfg.items():
  438. cfg[ordered_scope][key].extend(values)
  439. cfg['default'][key].extend(values)
  440. return cfg
  441. def set_config(self, key: str, value: str, *, append: bool,
  442. scope: GitConfigScope):
  443. key = canonicalize_git_config_key(key)
  444. with self._editable_scope(scope) as cfg:
  445. cur = cfg.get(key)
  446. if cur is None:
  447. cfg[key] = [value]
  448. return
  449. if append:
  450. cfg[key] = cur + [value]
  451. return
  452. if len(cur) == 1:
  453. cfg[key] = [value]
  454. return
  455. raise ValueError(f'GitConfigStateTest: Cannot set key {key} '
  456. f'- current value {cur!r} is multiple.')
  457. def set_config_multi(self, key: str, value: str, *,
  458. value_pattern: Optional[str], scope: GitConfigScope):
  459. key = canonicalize_git_config_key(key)
  460. with self._editable_scope(scope) as cfg:
  461. cur = cfg.get(key)
  462. if value_pattern is None or cur is None:
  463. cfg[key] = [value]
  464. return
  465. # We want to insert `value` in place of the first pattern match - if
  466. # multiple values match, they will all be removed.
  467. pat = re.compile(value_pattern)
  468. newval = []
  469. added = False
  470. for val in cur:
  471. if pat.match(val):
  472. if not added:
  473. newval.append(value)
  474. added = True
  475. else:
  476. newval.append(val)
  477. if not added:
  478. newval.append(value)
  479. cfg[key] = newval
  480. def unset_config(self, key: str, *, scope: GitConfigScope,
  481. missing_ok: bool):
  482. key = canonicalize_git_config_key(key)
  483. with self._editable_scope(scope) as cfg:
  484. cur = cfg.get(key)
  485. if cur is None:
  486. if missing_ok:
  487. return
  488. raise GitConfigUnsetMissingValue(key, scope)
  489. if len(cur) == 1:
  490. del cfg[key]
  491. return
  492. raise GitConfigUnsetMultipleValues(key, scope)
  493. def unset_config_multi(self, key: str, *, value_pattern: Optional[str],
  494. scope: GitConfigScope, missing_ok: bool):
  495. key = canonicalize_git_config_key(key)
  496. with self._editable_scope(scope) as cfg:
  497. cur = cfg.get(key)
  498. if cur is None:
  499. if not missing_ok:
  500. raise GitConfigUnsetMissingValue(key, scope)
  501. return
  502. if value_pattern is None:
  503. del cfg[key]
  504. return
  505. if cur is None:
  506. del cfg[key]
  507. return
  508. pat = re.compile(value_pattern)
  509. cfg[key] = [v for v in cur if not pat.match(v)]
  510. class GIT(object):
  511. current_version = None
  512. rev_parse_cache = {}
  513. # Maps cwd -> {config key, [config values]}
  514. # This cache speeds up all `git config ...` operations by only running a
  515. # single subcommand, which can greatly accelerate things like
  516. # git-map-branches.
  517. _CONFIG_CACHE: Dict[pathlib.Path, Optional[CachedGitConfigState]] = {}
  518. _CONFIG_CACHE_LOCK = threading.Lock()
  519. @classmethod
  520. def drop_config_cache(cls):
  521. """Completely purges all cached git config data.
  522. This should always be safe to call (it will be lazily repopulated), but
  523. really is only meant to be called from tests.
  524. """
  525. with cls._CONFIG_CACHE_LOCK:
  526. cls._CONFIG_CACHE = {}
  527. @staticmethod
  528. def _new_config_state(root: pathlib.Path) -> GitConfigStateBase:
  529. """_new_config_state is mocked in tests/scm_mock to return
  530. a GitConfigStateTest."""
  531. return GitConfigStateReal(root)
  532. @classmethod
  533. def _get_config_state(cls, cwd: str) -> CachedGitConfigState:
  534. key = pathlib.Path(cwd).absolute()
  535. with cls._CONFIG_CACHE_LOCK:
  536. cur = GIT._CONFIG_CACHE.get(key, None)
  537. if cur is not None:
  538. return cur
  539. ret = CachedGitConfigState(cls._new_config_state(key))
  540. cls._CONFIG_CACHE[key] = ret
  541. return ret
  542. @classmethod
  543. def _dump_config_state(cls) -> Dict[str, GitFlatConfigData]:
  544. """Dump internal config state.
  545. Used for testing. This will NOT work properly in non-test
  546. contexts as it relies on internal caches.
  547. """
  548. with cls._CONFIG_CACHE_LOCK:
  549. state = {}
  550. for key, val in cls._CONFIG_CACHE.items():
  551. if val is not None:
  552. state[str(key)] = val._maybe_load_config().get(
  553. 'default', {})
  554. return state
  555. @staticmethod
  556. def ApplyEnvVars(kwargs):
  557. env = kwargs.pop('env', None) or os.environ.copy()
  558. # Don't prompt for passwords; just fail quickly and noisily.
  559. # By default, git will use an interactive terminal prompt when a
  560. # username/ password is needed. That shouldn't happen in the chromium
  561. # workflow, and if it does, then gclient may hide the prompt in the
  562. # midst of a flood of terminal spew. The only indication that something
  563. # has gone wrong will be when gclient hangs unresponsively. Instead, we
  564. # disable the password prompt and simply allow git to fail noisily. The
  565. # error message produced by git will be copied to gclient's output.
  566. env.setdefault('GIT_ASKPASS', 'true')
  567. env.setdefault('SSH_ASKPASS', 'true')
  568. # 'cat' is a magical git string that disables pagers on all platforms.
  569. env.setdefault('GIT_PAGER', 'cat')
  570. return env
  571. @staticmethod
  572. def Capture(args, cwd=None, strip_out=True, **kwargs) -> str | bytes:
  573. kwargs.setdefault('env', GIT.ApplyEnvVars(kwargs))
  574. kwargs.setdefault('cwd', cwd)
  575. kwargs.setdefault('autostrip', strip_out)
  576. return git_common.run(*args, **kwargs)
  577. @staticmethod
  578. def CaptureStatus(
  579. cwd: str,
  580. upstream_branch: str,
  581. end_commit: Optional[str] = None,
  582. ignore_submodules: bool = True) -> Sequence[tuple[str, str]]:
  583. """Returns git status.
  584. Returns an array of (status, file) tuples."""
  585. if end_commit is None:
  586. end_commit = ''
  587. if upstream_branch is None:
  588. upstream_branch = GIT.GetUpstreamBranch(cwd)
  589. if upstream_branch is None:
  590. raise gclient_utils.Error('Cannot determine upstream branch')
  591. command = [
  592. '-c', 'core.quotePath=false', 'diff', '--name-status',
  593. '--no-renames'
  594. ]
  595. if ignore_submodules:
  596. command.append('--ignore-submodules=all')
  597. command.extend(['-r', '%s...%s' % (upstream_branch, end_commit)])
  598. status = GIT.Capture(command, cwd)
  599. assert isinstance(status, str)
  600. results = []
  601. if status:
  602. for statusline in status.splitlines():
  603. # 3-way merges can cause the status can be 'MMM' instead of 'M'.
  604. # This can happen when the user has 2 local branches and he
  605. # diffs between these 2 branches instead diffing to upstream.
  606. m = re.match(r'^(\w)+\t(.+)$', statusline)
  607. if not m:
  608. raise gclient_utils.Error(
  609. 'status currently unsupported: %s' % statusline)
  610. # Only grab the first letter.
  611. results.append(('%s ' % m.group(1)[0], m.group(2)))
  612. return results
  613. @staticmethod
  614. def GetConfig(cwd: str,
  615. key: str,
  616. default: Optional[str] = None,
  617. scope: Optional[str] = None) -> Optional[str]:
  618. """Lazily loads all configration observable for this CachedGitConfigState,
  619. then returns the last value for `key` as a string.
  620. If `key` is missing, returns default.
  621. """
  622. return GIT._get_config_state(cwd).GetConfig(key, default, scope)
  623. @staticmethod
  624. def GetConfigBool(cwd: str, key: str) -> bool:
  625. """Returns the booleanized value of `key`.
  626. This follows `git config` semantics (i.e. it normalizes the string value
  627. of the config value to "true" - all other string values return False).
  628. """
  629. return GIT._get_config_state(cwd).GetConfigBool(key)
  630. @staticmethod
  631. def GetConfigList(cwd: str, key: str) -> list[str]:
  632. """Returns all values of `key` as a list of strings."""
  633. return GIT._get_config_state(cwd).GetConfigList(key)
  634. @staticmethod
  635. def YieldConfigRegexp(
  636. cwd: str,
  637. pattern: Optional[str] = None) -> Iterable[tuple[str, str]]:
  638. """Yields (key, value) pairs for any config keys matching `pattern`.
  639. This use re.match, so `pattern` needs to be for the entire config key.
  640. If pattern is None, this returns all config items.
  641. """
  642. yield from GIT._get_config_state(cwd).YieldConfigRegexp(pattern)
  643. @staticmethod
  644. def GetBranchConfig(cwd: str,
  645. branch: str,
  646. key: str,
  647. default: Optional[str] = None) -> Optional[str]:
  648. assert branch, 'A branch must be given'
  649. key = 'branch.%s.%s' % (branch, key)
  650. return GIT.GetConfig(cwd, key, default)
  651. @staticmethod
  652. def SetConfig(cwd: str,
  653. key: str,
  654. value: Optional[str] = None,
  655. *,
  656. append: bool = False,
  657. missing_ok: bool = True,
  658. modify_all: bool = False,
  659. scope: GitConfigScope = 'local',
  660. value_pattern: Optional[str] = None):
  661. """Sets or unsets one or more config values.
  662. Args:
  663. cwd: path to set `git config` for.
  664. key: The specific config key to affect.
  665. value: The value to set. If this is None, `key` will be unset.
  666. append: If True and `value` is not None, this will append
  667. the value instead of replacing an existing one. Must not be
  668. specified with value_pattern.
  669. missing_ok: If `value` is None (i.e. this is an unset operation),
  670. ignore retcode=5 from `git config` (meaning that the value is
  671. not present). If `value` is not None, then this option has no
  672. effect. If this is false and the key is missing, this will raise
  673. GitConfigUnsetMissingValue.
  674. modify_all: If True, this will change a set operation to
  675. `--replace-all`, and will change an unset operation to
  676. `--unset-all`. Must not be specified with value_pattern.
  677. scope: By default this is the `local` scope, but could be `global`
  678. or `worktree`, depending on which config scope you want to affect.
  679. Note that the `system` scope cannot be modified.
  680. value_pattern: For use with `modify_all=True`, allows
  681. further filtering of the set or unset operation based on
  682. the currently configured value. Ignored for
  683. `modify_all=False`.
  684. """
  685. GIT._get_config_state(cwd).SetConfig(key,
  686. value,
  687. append=append,
  688. missing_ok=missing_ok,
  689. modify_all=modify_all,
  690. scope=scope,
  691. value_pattern=value_pattern)
  692. @staticmethod
  693. def SetBranchConfig(cwd, branch, key, value=None):
  694. assert branch, 'A branch must be given'
  695. key = 'branch.%s.%s' % (branch, key)
  696. GIT.SetConfig(cwd, key, value)
  697. @staticmethod
  698. def ShortBranchName(branch):
  699. """Converts a name like 'refs/heads/foo' to just 'foo'."""
  700. return branch.replace('refs/heads/', '')
  701. @staticmethod
  702. def GetBranchRef(cwd):
  703. """Returns the full branch reference, e.g. 'refs/heads/main'."""
  704. try:
  705. return GIT.Capture(['symbolic-ref', 'HEAD'], cwd=cwd)
  706. except subprocess2.CalledProcessError:
  707. return None
  708. @staticmethod
  709. def GetRemoteHeadRef(cwd, url, remote):
  710. """Returns the full default remote branch reference, e.g.
  711. 'refs/remotes/origin/main'."""
  712. if os.path.exists(cwd):
  713. ref = 'refs/remotes/%s/HEAD' % remote
  714. try:
  715. # Try using local git copy first
  716. ref = GIT.Capture(['symbolic-ref', ref], cwd=cwd)
  717. assert isinstance(ref, str)
  718. if not ref.endswith('master'):
  719. return ref
  720. except subprocess2.CalledProcessError:
  721. pass
  722. try:
  723. # Check if there are changes in the default branch for this
  724. # particular repository.
  725. GIT.Capture(['remote', 'set-head', '-a', remote], cwd=cwd)
  726. return GIT.Capture(['symbolic-ref', ref], cwd=cwd)
  727. except subprocess2.CalledProcessError:
  728. pass
  729. try:
  730. # Fetch information from git server
  731. resp = GIT.Capture(['ls-remote', '--symref', url, 'HEAD'])
  732. assert isinstance(resp, str)
  733. regex = r'^ref: (.*)\tHEAD$'
  734. for line in resp.split('\n'):
  735. m = re.match(regex, line)
  736. if m:
  737. refpair = GIT.RefToRemoteRef(m.group(1), remote)
  738. assert isinstance(refpair, tuple)
  739. return ''.join(refpair)
  740. except subprocess2.CalledProcessError:
  741. pass
  742. # Return default branch
  743. return 'refs/remotes/%s/main' % remote
  744. @staticmethod
  745. def GetBranch(cwd):
  746. """Returns the short branch name, e.g. 'main'."""
  747. branchref = GIT.GetBranchRef(cwd)
  748. if branchref:
  749. return GIT.ShortBranchName(branchref)
  750. return None
  751. @staticmethod
  752. def GetRemoteBranches(cwd):
  753. return GIT.Capture(['branch', '-r'], cwd=cwd).split()
  754. @staticmethod
  755. def FetchUpstreamTuple(
  756. cwd: str,
  757. branch: Optional[str] = None
  758. ) -> tuple[Optional[str], Optional[str]]:
  759. """Returns a tuple containing remote and remote ref,
  760. e.g. 'origin', 'refs/heads/main'
  761. """
  762. try:
  763. branch = branch or GIT.GetBranch(cwd)
  764. except subprocess2.CalledProcessError:
  765. pass
  766. if branch:
  767. upstream_branch = GIT.GetBranchConfig(cwd, branch, 'merge')
  768. if upstream_branch:
  769. remote = GIT.GetBranchConfig(cwd, branch, 'remote', '.')
  770. return remote, upstream_branch
  771. upstream_branch = GIT.GetConfig(cwd, 'rietveld.upstream-branch')
  772. if upstream_branch:
  773. remote = GIT.GetConfig(cwd, 'rietveld.upstream-remote', '.')
  774. return remote, upstream_branch
  775. # Else, try to guess the origin remote.
  776. remote_branches = GIT.GetRemoteBranches(cwd)
  777. if 'origin/main' in remote_branches:
  778. # Fall back on origin/main if it exits.
  779. return 'origin', 'refs/heads/main'
  780. if 'origin/master' in remote_branches:
  781. # Fall back on origin/master if it exits.
  782. return 'origin', 'refs/heads/master'
  783. return None, None
  784. @staticmethod
  785. def RefToRemoteRef(ref, remote) -> Optional[tuple[str, str]]:
  786. """Convert a checkout ref to the equivalent remote ref.
  787. Returns:
  788. A tuple of the remote ref's (common prefix, unique suffix), or None if it
  789. doesn't appear to refer to a remote ref (e.g. it's a commit hash).
  790. """
  791. # TODO(mmoss): This is just a brute-force mapping based of the expected
  792. # git config. It's a bit better than the even more brute-force
  793. # replace('heads', ...), but could still be smarter (like maybe actually
  794. # using values gleaned from the git config).
  795. m = re.match('^(refs/(remotes/)?)?branch-heads/', ref or '')
  796. if m:
  797. return ('refs/remotes/branch-heads/', ref.replace(m.group(0), ''))
  798. m = re.match('^((refs/)?remotes/)?%s/|(refs/)?heads/' % remote, ref
  799. or '')
  800. if m:
  801. return ('refs/remotes/%s/' % remote, ref.replace(m.group(0), ''))
  802. return None
  803. @staticmethod
  804. def RemoteRefToRef(ref, remote):
  805. assert remote, 'A remote must be given'
  806. if not ref or not ref.startswith('refs/'):
  807. return None
  808. if not ref.startswith('refs/remotes/'):
  809. return ref
  810. if ref.startswith('refs/remotes/branch-heads/'):
  811. return 'refs' + ref[len('refs/remotes'):]
  812. if ref.startswith('refs/remotes/%s/' % remote):
  813. return 'refs/heads' + ref[len('refs/remotes/%s' % remote):]
  814. return None
  815. @staticmethod
  816. def GetUpstreamBranch(cwd) -> Optional[str]:
  817. """Gets the current branch's upstream branch."""
  818. remote, upstream_branch = GIT.FetchUpstreamTuple(cwd)
  819. if remote != '.' and upstream_branch:
  820. remote_ref = GIT.RefToRemoteRef(upstream_branch, remote)
  821. if remote_ref:
  822. upstream_branch = ''.join(remote_ref)
  823. return upstream_branch
  824. @staticmethod
  825. def IsAncestor(maybe_ancestor: str,
  826. ref: str,
  827. cwd: Optional[str] = None) -> bool:
  828. """Verifies if |maybe_ancestor| is an ancestor of |ref|."""
  829. try:
  830. GIT.Capture(['merge-base', '--is-ancestor', maybe_ancestor, ref],
  831. cwd=cwd)
  832. return True
  833. except subprocess2.CalledProcessError:
  834. return False
  835. @staticmethod
  836. def GetOldContents(cwd, filename, branch=None):
  837. if not branch:
  838. branch = GIT.GetUpstreamBranch(cwd)
  839. if platform.system() == 'Windows':
  840. # git show <sha>:<path> wants a posix path.
  841. filename = filename.replace('\\', '/')
  842. command = ['show', '%s:%s' % (branch, filename)]
  843. try:
  844. return GIT.Capture(command, cwd=cwd, strip_out=False)
  845. except subprocess2.CalledProcessError:
  846. return ''
  847. @staticmethod
  848. def GenerateDiff(cwd: str,
  849. branch: Optional[str] = None,
  850. branch_head: str = 'HEAD',
  851. full_move: bool = False,
  852. files: Optional[Iterable[str]] = None) -> str:
  853. """Diffs against the upstream branch or optionally another branch.
  854. full_move means that move or copy operations should completely recreate the
  855. files, usually in the prospect to apply the patch for a try job."""
  856. if not branch:
  857. branch = GIT.GetUpstreamBranch(cwd)
  858. assert isinstance(branch, str)
  859. command = [
  860. '-c',
  861. 'core.quotePath=false',
  862. 'diff',
  863. '-p',
  864. '--no-color',
  865. '--no-prefix',
  866. '--no-ext-diff',
  867. branch + "..." + branch_head,
  868. ]
  869. if full_move:
  870. command.append('--no-renames')
  871. else:
  872. command.append('-C')
  873. # TODO(maruel): --binary support.
  874. if files:
  875. command.append('--')
  876. command.extend(files)
  877. output = GIT.Capture(command, cwd=cwd, strip_out=False)
  878. assert isinstance(output, str)
  879. diff = output.splitlines(True)
  880. for i in range(len(diff)):
  881. # In the case of added files, replace /dev/null with the path to the
  882. # file being added.
  883. if diff[i].startswith('--- /dev/null'):
  884. diff[i] = '--- %s' % diff[i + 1][4:]
  885. return ''.join(diff)
  886. @staticmethod
  887. def GetAllFiles(cwd):
  888. """Returns the list of all files under revision control."""
  889. command = ['-c', 'core.quotePath=false', 'ls-files', '--', '.']
  890. return GIT.Capture(command, cwd=cwd).splitlines(False)
  891. @staticmethod
  892. def GetSubmoduleCommits(cwd: str,
  893. submodules: list[str]) -> Mapping[str, str]:
  894. """Returns a mapping of staged or committed new commits for submodules."""
  895. if not submodules:
  896. return {}
  897. result = subprocess2.check_output(['git', 'ls-files', '-s', '--'] +
  898. submodules,
  899. cwd=cwd).decode('utf-8')
  900. commit_hashes = {}
  901. for r in result.splitlines():
  902. # ['<mode>', '<commit_hash>', '<stage_number>', '<path>'].
  903. record = r.strip().split(maxsplit=3) # path can contain spaces.
  904. assert record[0] == '160000', 'file is not a gitlink: %s' % record
  905. commit_hashes[record[3]] = record[1]
  906. return commit_hashes
  907. @staticmethod
  908. def GetCheckoutRoot(cwd) -> str:
  909. """Returns the top level directory of a git checkout as an absolute path.
  910. """
  911. root = GIT.Capture(['rev-parse', '--show-cdup'], cwd=cwd)
  912. assert isinstance(root, str)
  913. return os.path.abspath(os.path.join(cwd, root))
  914. @staticmethod
  915. def IsInsideWorkTree(cwd):
  916. try:
  917. return GIT.Capture(['rev-parse', '--is-inside-work-tree'], cwd=cwd)
  918. except (OSError, subprocess2.CalledProcessError):
  919. return False
  920. @staticmethod
  921. def IsVersioned(cwd: str, relative_dir: str) -> int:
  922. """Checks whether the given |relative_dir| is part of cwd's repo."""
  923. output = GIT.Capture(['ls-tree', 'HEAD', '--', relative_dir], cwd=cwd)
  924. assert isinstance(output, str)
  925. if not output:
  926. return VERSIONED_NO
  927. if output.startswith('160000'):
  928. return VERSIONED_SUBMODULE
  929. return VERSIONED_DIR
  930. @staticmethod
  931. def ListSubmodules(repo_root: str) -> Collection[str]:
  932. """Returns the list of submodule paths for the given repo.
  933. Path separators will be adjusted for the current OS.
  934. """
  935. if not os.path.exists(os.path.join(repo_root, '.gitmodules')):
  936. return []
  937. try:
  938. config_output = GIT.Capture(
  939. ['config', '--file', '.gitmodules', '--get-regexp', 'path'],
  940. cwd=repo_root)
  941. except subprocess2.CalledProcessError:
  942. # Git exits with 1 if no config matches are found.
  943. return []
  944. assert isinstance(config_output, str)
  945. return [
  946. line.split()[-1].replace('/', os.path.sep)
  947. for line in config_output.splitlines()
  948. ]
  949. @staticmethod
  950. def CleanupDir(cwd, relative_dir):
  951. """Cleans up untracked file inside |relative_dir|."""
  952. return bool(GIT.Capture(['clean', '-df', relative_dir], cwd=cwd))
  953. @staticmethod
  954. def ResolveCommit(cwd, rev):
  955. cache_key = None
  956. # We do this instead of rev-parse --verify rev^{commit}, since on
  957. # Windows git can be either an executable or batch script, each of which
  958. # requires escaping the caret (^) a different way.
  959. if gclient_utils.IsFullGitSha(rev):
  960. # Only cache full SHAs
  961. cache_key = hash(cwd + rev)
  962. if val := GIT.rev_parse_cache.get(cache_key):
  963. return val
  964. # git-rev parse --verify FULL_GIT_SHA always succeeds, even if we
  965. # don't have FULL_GIT_SHA locally. Removing the last character
  966. # forces git to check if FULL_GIT_SHA refers to an object in the
  967. # local database.
  968. rev = rev[:-1]
  969. res = GIT.Capture(['rev-parse', '--quiet', '--verify', rev], cwd=cwd)
  970. if cache_key:
  971. # We don't expect concurrent execution, so we don't lock anything.
  972. GIT.rev_parse_cache[cache_key] = res
  973. return res
  974. @staticmethod
  975. def IsValidRevision(cwd, rev, sha_only=False):
  976. """Verifies the revision is a proper git revision.
  977. sha_only: Fail unless rev is a sha hash.
  978. """
  979. try:
  980. sha = GIT.ResolveCommit(cwd, rev)
  981. except subprocess2.CalledProcessError:
  982. return None
  983. if sha_only:
  984. return sha == rev.lower()
  985. return True
  986. class DIFF(object):
  987. @staticmethod
  988. def GetAllFiles(cwd):
  989. """Return all files under the repo at cwd.
  990. If .gitmodules exists in cwd, use it to determine which folders are
  991. submodules and don't recurse into them. Submodule paths are returned.
  992. """
  993. # `git config --file` works outside of a git workspace.
  994. submodules = GIT.ListSubmodules(cwd)
  995. if not submodules:
  996. return [
  997. str(p.relative_to(cwd)) for p in pathlib.Path(cwd).rglob("*")
  998. if p.is_file()
  999. ]
  1000. full_path_submodules = {os.path.join(cwd, s) for s in submodules}
  1001. def should_recurse(dirpath, dirname):
  1002. full_path = os.path.join(dirpath, dirname)
  1003. return full_path not in full_path_submodules
  1004. paths = list(full_path_submodules)
  1005. for dirpath, dirnames, filenames in os.walk(cwd):
  1006. paths.extend([os.path.join(dirpath, f) for f in filenames])
  1007. dirnames[:] = [d for d in dirnames if should_recurse(dirpath, d)]
  1008. return [os.path.relpath(p, cwd) for p in paths]
  1009. # vim: sts=4:ts=4:sw=4:tw=80:et: