scm.py 44 KB

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