scm.py 45 KB

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