taskcluster-taskgraph 24.2.2__py3-none-any.whl → 24.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: taskcluster-taskgraph
3
- Version: 24.2.2
3
+ Version: 24.3.0
4
4
  Summary: Build taskcluster taskgraphs
5
5
  Project-URL: Repository, https://github.com/taskcluster/taskgraph
6
6
  Project-URL: Issues, https://github.com/taskcluster/taskgraph/issues
@@ -5,7 +5,7 @@ taskgraph/decision.py,sha256=V5TUWSeIlUbKcRFMBGhYd3t7wLmd2rwAgdx6jN4Yt1Q,11769
5
5
  taskgraph/docker.py,sha256=5k9ZjooqvtBDtb3AAZijUdUNzgRcm6CXM5CrmLO0KFA,24429
6
6
  taskgraph/filter_tasks.py,sha256=R7tYXiaVPGIkQ6O1c9-QJrKZ59m9pFXCloUlPraVnZU,866
7
7
  taskgraph/generator.py,sha256=zanO2GOb0HWEGOQkEZGmXP0JySql71xoeENjk-cKOc8,23170
8
- taskgraph/graph.py,sha256=GGNY9n44xVgz_LbnprbQDY1IFcNV9oxhrBavu3SW-V8,6202
8
+ taskgraph/graph.py,sha256=Gnv4pC2sRn4rVPNegnXD1ni3eUb1ULju04zq-6bmH0A,6304
9
9
  taskgraph/main.py,sha256=miuQkXuxD9eAUa0MqORARdyCRVtsqobLA-vW6n1sf_Y,39424
10
10
  taskgraph/morph.py,sha256=pF9_bqM2aNpNfSH7xEFWQz83ohxxVdUUDqdv2yq7waA,9232
11
11
  taskgraph/parameters.py,sha256=dlEC048z06WKdIL8QhH1nu2hy9jF4AQ4KcVfJauIXyk,13867
@@ -26,10 +26,10 @@ taskgraph/loader/transform.py,sha256=_PPHSLdnqa8MUlXqIZSkRsGAndQBVTZjNLAaIUVU5ps
26
26
  taskgraph/optimize/__init__.py,sha256=Oqpq1RW8QzOcu7zaMlNQ3BHT9ws9e_93FWfCqzNcQps,123
27
27
  taskgraph/optimize/base.py,sha256=c2ETEJChLCFm6yoA-IcWepu7V0eOIA_e_tQfFwld-EA,20449
28
28
  taskgraph/optimize/strategies.py,sha256=SAzMXbp_oQ3EJ2pDLOvShh2RosJExCRTZR4PFsD-3l0,3776
29
- taskgraph/run-task/fetch-content,sha256=ZpjiX-SmosoKBGyG-GxWtAGvUCzspWMrmoFQXhbhw8s,36209
29
+ taskgraph/run-task/fetch-content,sha256=0QsAbgf8O65N8kvfFc2d0YjOKl-oqATXhRLBeXwRwTE,39216
30
30
  taskgraph/run-task/hgrc,sha256=BybWLDR89bWi3pE5T05UqmDHs02CbLypE-omLZWU6Uk,896
31
- taskgraph/run-task/robustcheckout.py,sha256=DZ-MB68P7FmZY1vkj7o3Xp-d21aOiFnLMimv25S3FTs,30778
32
- taskgraph/run-task/run-task,sha256=yEQqTaw2u7hgFqlUmWsN10J89GFarZhQtIheFl6WI0I,47409
31
+ taskgraph/run-task/robustcheckout.py,sha256=85tcHqlAlWre1RYJ5qu3CJtqAAsMnB9AlDeN4zfpOrQ,33021
32
+ taskgraph/run-task/run-task,sha256=DRpXBcHXLmfLPwsz7S0IJwcWC0XFIDiE99Pu2yZCZrA,58070
33
33
  taskgraph/transforms/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
34
34
  taskgraph/transforms/base.py,sha256=NULQNfwJfBTo0Bem1hp_6aDztUABwbnJrQ0MBpOgGDE,5125
35
35
  taskgraph/transforms/cached_tasks.py,sha256=Izxfl4_UG56Hk0W2vIj5OK8hji7adG2zfvtELJNaE6Q,2592
@@ -40,7 +40,7 @@ taskgraph/transforms/fetch.py,sha256=Rf8FGMDY84sLNKp1mw7MVQspiJvses5-njWmIBIi0AQ
40
40
  taskgraph/transforms/from_deps.py,sha256=hifdCsKY_kiBzUCxsOAfCR0VR525x-H0kWNutygTDCQ,8085
41
41
  taskgraph/transforms/matrix.py,sha256=anoUjnPnpvguQgHkj2HiVdHs3hDCROxQ6Wb0dLJ6l_8,3069
42
42
  taskgraph/transforms/notify.py,sha256=KTku8694xEicls5vhUC4Uvpg-aaSwDUsVizVlR5RObw,6360
43
- taskgraph/transforms/task.py,sha256=DhQBnEgqvJw_qdvXuwHirwS8oL08iIeDP_sO4a238R4,43139
43
+ taskgraph/transforms/task.py,sha256=6OP9b-EWc4AZjfBF-Dh4h7nIyB5DLcwUaAMNAFt8h-g,43148
44
44
  taskgraph/transforms/task_context.py,sha256=diijTlofODLCkgc0ZDR02_VojGXDW8m54lAvEkUIpBc,5144
45
45
  taskgraph/transforms/run/__init__.py,sha256=s9GkP3rYtLUa8IbpfZQC-x5YiiQSQHjHVfgf8jaEOWg,18195
46
46
  taskgraph/transforms/run/common.py,sha256=ANiOZJ0sSl1lL09Qo3aTESFd1gK6SvwLX8cTj_J2SmU,6861
@@ -75,8 +75,8 @@ taskgraph/util/vcs.py,sha256=Z2sCJT0-kwP_AbSUMIzya9lk8SyjWPzMYtQPYJ2WtnA,22496
75
75
  taskgraph/util/verify.py,sha256=hY6xl4rEgXeVuPswG8HCGBHJNKHF2QXx2cTxrrRtDeE,14829
76
76
  taskgraph/util/workertypes.py,sha256=SpqQmf99kB9QxtgOdXeMUvzuVv1o0ZVB4lYahZH3jDY,2534
77
77
  taskgraph/util/yaml.py,sha256=29h6RE7JA4z0U2V3WCu-S39lPMBS9CEZEglv1delDvw,1075
78
- taskcluster_taskgraph-24.2.2.dist-info/METADATA,sha256=dgtz4W6pVH4-KRYuFfTfcs7rJyUUq6AnEVJ9uT56stk,5065
79
- taskcluster_taskgraph-24.2.2.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
80
- taskcluster_taskgraph-24.2.2.dist-info/entry_points.txt,sha256=2hxDzE3qq_sHh-J3ROqwpxgQgxO-196phWAQREl2-XA,50
81
- taskcluster_taskgraph-24.2.2.dist-info/licenses/LICENSE,sha256=HyVuytGSiAUQ6ErWBHTqt1iSGHhLmlC8fO7jTCuR8dU,16725
82
- taskcluster_taskgraph-24.2.2.dist-info/RECORD,,
78
+ taskcluster_taskgraph-24.3.0.dist-info/METADATA,sha256=CNocuQbuMOJ1k24k2MTavl1rSVvRmBes4ny6OJyKYek,5065
79
+ taskcluster_taskgraph-24.3.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
80
+ taskcluster_taskgraph-24.3.0.dist-info/entry_points.txt,sha256=2hxDzE3qq_sHh-J3ROqwpxgQgxO-196phWAQREl2-XA,50
81
+ taskcluster_taskgraph-24.3.0.dist-info/licenses/LICENSE,sha256=HyVuytGSiAUQ6ErWBHTqt1iSGHhLmlC8fO7jTCuR8dU,16725
82
+ taskcluster_taskgraph-24.3.0.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.31.0
2
+ Generator: hatchling 1.32.0
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
taskgraph/graph.py CHANGED
@@ -61,20 +61,24 @@ class Graph(_Graph):
61
61
  f"Unknown nodes in transitive closure: {nodes - self.nodes}"
62
62
  )
63
63
 
64
- # generate a new graph by expanding along edges until reaching a fixed
65
- # point
66
- new_nodes, new_edges = nodes, set()
67
- nodes, edges = set(), set()
68
- while (new_nodes, new_edges) != (nodes, edges):
69
- nodes, edges = new_nodes, new_edges
70
- add_edges = {
71
- (left, right, name)
72
- for (left, right, name) in self.edges
73
- if (right if reverse else left) in nodes
74
- }
75
- add_nodes = {(left if reverse else right) for (left, right, _) in add_edges}
76
- new_nodes = nodes | add_nodes
77
- new_edges = edges | add_edges
64
+ # Build an adjacency map keyed by the node to expand from. This reduces
65
+ # traversal below from O(V·E) -> O(V+E).
66
+ adjacency = collections.defaultdict(list)
67
+ for edge in self.edges:
68
+ left, right, _ = edge
69
+ adjacency[right if reverse else left].append(edge)
70
+
71
+ new_nodes = set(nodes)
72
+ new_edges = set()
73
+ queue = collections.deque(nodes)
74
+ while queue:
75
+ node = queue.popleft()
76
+ for edge in adjacency.get(node, ()):
77
+ new_edges.add(edge)
78
+ neighbor = edge[0] if reverse else edge[1]
79
+ if neighbor not in new_nodes:
80
+ new_nodes.add(neighbor)
81
+ queue.append(neighbor)
78
82
  return Graph(new_nodes, new_edges)
79
83
 
80
84
  def _visit(self, reverse):
@@ -18,6 +18,7 @@ import os
18
18
  import pathlib
19
19
  import random
20
20
  import re
21
+ import shutil
21
22
  import ssl
22
23
  import stat
23
24
  import subprocess
@@ -377,6 +378,70 @@ def archive_type(path: pathlib.Path):
377
378
  return None
378
379
 
379
380
 
381
+ class TarFile(tarfile.TarFile):
382
+ # When a tarball contains symbolic links, the python tarfile module ends up
383
+ # trying to seek in the tar, which doesn't quite work when the tar is a
384
+ # stream (which it is when decompressing at the same time). We wrap TarFile
385
+ # so as to handle the symbolic link situation ourselves, and when we can't
386
+ # create symbolic links (e.g. on Windows), we instead create a copy of the
387
+ # file the link points to.
388
+ def _do_extract(
389
+ self, extract, member, path="", set_attrs=True, numeric_owner=False, **kwargs
390
+ ):
391
+ deferred_links = getattr(self, "_deferred_links", None)
392
+ if not isinstance(member, tarfile.TarInfo):
393
+ member = self.getmember(member)
394
+ targetpath = os.path.normcase(os.path.join(path, member.name))
395
+
396
+ if deferred_links is not None and member.issym():
397
+ if os.path.lexists(targetpath):
398
+ # Avoid FileExistsError on following os.symlink.
399
+ os.unlink(targetpath)
400
+ try:
401
+ os.symlink(member.linkname, targetpath)
402
+ except (NotImplementedError, OSError):
403
+ # On Windows, os.symlink can fail, in this case fallback to
404
+ # creating a copy. If the destination was not already created,
405
+ # defer the link creation.
406
+ source = os.path.normcase(
407
+ os.path.join(os.path.dirname(targetpath), member.linkname)
408
+ )
409
+
410
+ if source in self._extracted_members:
411
+ shutil.copy(source, targetpath)
412
+ self.chown(member, targetpath, numeric_owner)
413
+ else:
414
+ deferred_links.setdefault(source, []).append(
415
+ (member, targetpath, numeric_owner)
416
+ )
417
+ return
418
+
419
+ extract(member, path, set_attrs, numeric_owner=numeric_owner, **kwargs)
420
+ if deferred_links is not None:
421
+ for tarinfo, linkpath, numeric_owner in deferred_links.pop(targetpath, []):
422
+ shutil.copy(targetpath, linkpath)
423
+ self.chown(tarinfo, linkpath, numeric_owner)
424
+ self._extracted_members.add(targetpath)
425
+
426
+ def extract(self, *args, **kwargs):
427
+ self._do_extract(super(TarFile, self).extract, *args, **kwargs)
428
+
429
+ # extractall in versions for cpython that implement PEP 706 call _extract_one
430
+ # instead of extract.
431
+ def _extract_one(self, *args, **kwargs):
432
+ self._do_extract(super(TarFile, self)._extract_one, *args, **kwargs)
433
+
434
+ def extractall(self, *args, **kwargs):
435
+ self._deferred_links = {}
436
+ self._extracted_members = set()
437
+ super(TarFile, self).extractall(*args, **kwargs)
438
+ for links in self._deferred_links.values():
439
+ for tarinfo, linkpath, numeric_owner in links:
440
+ log(f"Cannot create dangling symbolic link: {linkpath}")
441
+ delattr(self, "_deferred_links")
442
+ delattr(self, "_extracted_members")
443
+
444
+
380
445
  def extract_archive(path, dest_dir):
381
446
  """Extract an archive to a destination directory."""
382
447
 
@@ -398,7 +463,7 @@ def extract_archive(path, dest_dir):
398
463
  # also not much slower on Windows, presumably because of the
399
464
  # notoriously bad I/O).
400
465
  if sys.platform == "win32":
401
- tar = tarfile.open(fileobj=ifh, mode="r|")
466
+ tar = TarFile.open(fileobj=ifh, mode="r|")
402
467
  tar.extractall(str(dest_dir))
403
468
  args = []
404
469
  else:
@@ -18,6 +18,7 @@ import random
18
18
  import re
19
19
  import socket
20
20
  import ssl
21
+ import stat
21
22
  import time
22
23
 
23
24
  from mercurial.i18n import _
@@ -38,6 +39,22 @@ from mercurial import (
38
39
  vfs,
39
40
  )
40
41
 
42
+ # TRACKING hg72 - `hg.repository`/`hg.peer` moved to `mercurial.repo.factory`.
43
+ try:
44
+ from mercurial.repo import factory as _repofactory
45
+
46
+ _repository = _repofactory.repository
47
+ _peer = _repofactory.peer
48
+ except ImportError:
49
+ _repository = hg.repository
50
+ _peer = hg.peer
51
+
52
+ # TRACKING hg72 - `hg.clone` moved to `mercurial.cmd_impls.clone.clone`.
53
+ try:
54
+ from mercurial.cmd_impls.clone import clone as _clone
55
+ except ImportError:
56
+ _clone = hg.clone
57
+
41
58
  # Causes worker to purge caches on process exit and for task to retry.
42
59
  EXIT_PURGE_CACHE = 72
43
60
 
@@ -67,6 +84,57 @@ def peerlookup(remote, v):
67
84
  return e.callcommand(b"lookup", {b"key": v}).result()
68
85
 
69
86
 
87
+ def remove_dangling_links(ui, path):
88
+ """On Windows, remove dangling symlinks and junctions under ``path``.
89
+
90
+ npm's ``file:`` protocol dependencies create directory junctions in
91
+ ``node_modules/`` with absolute targets. When a Taskcluster cache
92
+ is restored to a different task directory those targets no longer
93
+ exist, and Mercurial's purge crashes in ``vfs.listdir()`` with
94
+ ``FileNotFoundError``. Removing them first lets purge proceed.
95
+ """
96
+ if os.name != "nt":
97
+ return
98
+
99
+ ui.write(b"windows detected, removing dangling links\n")
100
+
101
+ stack = [path]
102
+ while stack:
103
+ dirpath = stack.pop()
104
+ try:
105
+ entries = os.scandir(dirpath)
106
+ except OSError:
107
+ continue
108
+
109
+ with entries:
110
+ for entry in entries:
111
+ p = entry.path
112
+ try:
113
+ attrs = entry.stat(follow_symlinks=False).st_file_attributes
114
+ except OSError:
115
+ continue
116
+
117
+ if attrs & stat.FILE_ATTRIBUTE_REPARSE_POINT:
118
+ if not os.path.exists(p):
119
+ ui.write(
120
+ b"(removing dangling link %s)\n"
121
+ % os.fsencode(os.path.relpath(p, path))
122
+ )
123
+ try:
124
+ os.rmdir(p)
125
+ except OSError:
126
+ os.remove(p)
127
+ continue
128
+
129
+ try:
130
+ is_dir = entry.is_dir(follow_symlinks=False)
131
+ except OSError:
132
+ continue
133
+
134
+ if is_dir:
135
+ stack.append(p)
136
+
137
+
70
138
  @command(
71
139
  b"robustcheckout",
72
140
  [
@@ -427,7 +495,7 @@ def _docheckout(
427
495
  def handlerepoerror(e):
428
496
  if pycompat.bytestr(e) == _(b"abandoned transaction found"):
429
497
  ui.warn(b"(abandoned transaction found; trying to recover)\n")
430
- repo = hg.repository(ui, dest)
498
+ repo = _repository(ui, dest)
431
499
  if not repo.recover():
432
500
  ui.warn(b"(could not recover repo state; deleting shared store)\n")
433
501
  with timeit("remove_unrecovered_shared_store", "remove-store"):
@@ -535,7 +603,7 @@ def _docheckout(
535
603
  cloneurl = upstream or url
536
604
 
537
605
  try:
538
- clonepeer = hg.peer(ui, {}, cloneurl)
606
+ clonepeer = _peer(ui, {}, cloneurl)
539
607
  rootnode = peerlookup(clonepeer, b"0")
540
608
  except error.RepoLookupError:
541
609
  raise error.Abort(b"unable to resolve root revision from clone source")
@@ -623,12 +691,12 @@ def _docheckout(
623
691
  ui.write(b"(cloning from upstream repo %s)\n" % upstream)
624
692
 
625
693
  if not storevfs.exists():
626
- behaviors.add(b"create-store")
694
+ behaviors.add("create-store")
627
695
 
628
696
  try:
629
697
  with timeit("clone", "clone"):
630
698
  shareopts = {b"pool": sharebase, b"mode": b"identity"}
631
- res = hg.clone(
699
+ res = _clone(
632
700
  ui,
633
701
  {},
634
702
  clonepeer,
@@ -667,7 +735,7 @@ def _docheckout(
667
735
  # The destination .hg directory should exist. Now make sure we have the
668
736
  # wanted revision.
669
737
 
670
- repo = hg.repository(ui, dest)
738
+ repo = _repository(ui, dest)
671
739
 
672
740
  # We only pull if we are using symbolic names or the requested revision
673
741
  # doesn't exist.
@@ -694,7 +762,7 @@ def _docheckout(
694
762
 
695
763
  remote = None
696
764
  try:
697
- remote = hg.peer(repo, {}, url)
765
+ remote = _peer(repo, {}, url)
698
766
  pullrevs = [peerlookup(remote, revision or branch)]
699
767
  checkoutrevision = hex(pullrevs[0])
700
768
  if branch:
@@ -741,6 +809,10 @@ def _docheckout(
741
809
  # guaranteed to not have conflicts on `hg update`.
742
810
  if purge and not created:
743
811
  ui.write(b"(purging working directory)\n")
812
+
813
+ with timeit("purge", "dangling_link_remove"):
814
+ remove_dangling_links(ui, dest)
815
+
744
816
  purge = getattr(commands, "purge", None)
745
817
  if not purge:
746
818
  purge = extensions.find(b"purge").purge
@@ -27,12 +27,13 @@ import socket
27
27
  import stat
28
28
  import subprocess
29
29
  import sys
30
+ import tempfile
30
31
  import threading
31
32
  import time
32
33
  import urllib.error
33
34
  import urllib.request
34
35
  from pathlib import Path
35
- from typing import Dict, Optional
36
+ from typing import Dict, List, Literal, NamedTuple, Optional
36
37
 
37
38
  SECRET_BASEURL_TPL = "{}/secrets/v1/secret/{{}}".format(
38
39
  os.environ.get("TASKCLUSTER_PROXY_URL", "http://taskcluster").rstrip("/")
@@ -234,8 +235,8 @@ def remove(path):
234
235
  _call_windows_retry(shutil.rmtree, (path,))
235
236
 
236
237
 
237
- def run_required_command(prefix, args, *, extra_env=None, cwd=None):
238
- res = run_command(prefix, args, extra_env=extra_env, cwd=cwd)
238
+ def run_required_command(prefix, args, *, extra_env=None, cwd=None, stdin_data=None):
239
+ res = run_command(prefix, args, extra_env=extra_env, cwd=cwd, stdin_data=stdin_data)
239
240
  if res:
240
241
  sys.exit(res)
241
242
 
@@ -253,7 +254,7 @@ def retry_required_command(prefix, args, *, extra_env=None, cwd=None, retries=2)
253
254
  time.sleep(backoff)
254
255
 
255
256
 
256
- def run_command(prefix, args, *, extra_env=None, cwd=None):
257
+ def run_command(prefix, args, *, extra_env=None, cwd=None, stdin_data=None):
257
258
  """Runs a process and prefixes its output with the time.
258
259
 
259
260
  Returns the process exit code.
@@ -274,6 +275,12 @@ def run_command(prefix, args, *, extra_env=None, cwd=None):
274
275
  # we manually install a latin1 stream wrapper. This allows us to readline()
275
276
  # and preserves bytes, without losing any data.
276
277
 
278
+ stdin_file = None
279
+ if stdin_data is not None:
280
+ stdin_file = tempfile.TemporaryFile()
281
+ stdin_file.write(stdin_data)
282
+ stdin_file.seek(0)
283
+
277
284
  p = subprocess.Popen(
278
285
  args,
279
286
  # Disable buffering because we want to receive output
@@ -282,11 +289,14 @@ def run_command(prefix, args, *, extra_env=None, cwd=None):
282
289
  bufsize=0,
283
290
  stdout=subprocess.PIPE,
284
291
  stderr=subprocess.STDOUT,
285
- stdin=sys.stdin.fileno(),
292
+ stdin=stdin_file if stdin_file is not None else sys.stdin.fileno(),
286
293
  cwd=cwd,
287
294
  env=env,
288
295
  )
289
296
 
297
+ if stdin_file is not None:
298
+ stdin_file.close()
299
+
290
300
  stdout = io.TextIOWrapper(p.stdout, encoding="latin1")
291
301
 
292
302
  if os.getpid() == 1:
@@ -571,8 +581,9 @@ def configure_volume_posix(volume, user, group, running_as_root):
571
581
  class PerfRecorder:
572
582
  """Collects operation timings and emits them as PERFHERDER_DATA."""
573
583
 
574
- def __init__(self, framework: str):
584
+ def __init__(self, framework: str, extra_options: Optional[List[str]] = None):
575
585
  self.framework = framework
586
+ self.extra_options = list(extra_options or [])
576
587
  self.optimes = []
577
588
  self._stack = []
578
589
 
@@ -610,7 +621,7 @@ class PerfRecorder:
610
621
  "value": duration,
611
622
  "lowerIsBetter": True,
612
623
  "shouldAlert": False,
613
- "extraOptions": [instance_type],
624
+ "extraOptions": [instance_type, *self.extra_options],
614
625
  "subtests": [],
615
626
  }
616
627
  )
@@ -673,6 +684,176 @@ def _clean_git_checkout(destination_path):
673
684
  print_line(b"vcs", b"successfully cleaned git checkout!\n")
674
685
 
675
686
 
687
+ def _git_config(repo_path: str, key: str) -> Optional[str]:
688
+ p = subprocess.run(
689
+ ["git", "config", "--get", key],
690
+ stdout=subprocess.PIPE,
691
+ stderr=subprocess.PIPE,
692
+ cwd=repo_path,
693
+ env=os.environ,
694
+ encoding="utf-8",
695
+ )
696
+ if p.returncode == 1:
697
+ return None
698
+ if p.returncode != 0:
699
+ raise RuntimeError(f"git config --get {key} failed: {p.stderr.strip()}")
700
+ return p.stdout.strip()
701
+
702
+
703
+ def _git_lines(repo_path: str, *args: str) -> List[str]:
704
+ return subprocess.check_output(
705
+ ["git", *args], cwd=repo_path, env=os.environ, encoding="utf-8"
706
+ ).splitlines()
707
+
708
+
709
+ _PATTERN_METACHARS_RE = re.compile(r"([*?\[\\])")
710
+
711
+
712
+ def _escape_pattern(path: str) -> str:
713
+ escaped = _PATTERN_METACHARS_RE.sub(r"\\\1", path)
714
+ if escaped.endswith(" "):
715
+ escaped = escaped[:-1] + "\\ "
716
+ return escaped
717
+
718
+
719
+ def _cone_ancestor_files(
720
+ repo_path: str, rev: str, directories: List[str]
721
+ ) -> List[str]:
722
+ patterns = []
723
+ seen = set()
724
+ for directory in directories:
725
+ parts = directory.split("/")
726
+ for depth in range(len(parts)):
727
+ ancestor = "/".join(parts[:depth])
728
+ if ancestor in seen:
729
+ continue
730
+ seen.add(ancestor)
731
+ args = ["ls-tree", "-z", rev]
732
+ if ancestor:
733
+ args.append(f"{ancestor}/")
734
+ out = subprocess.check_output(
735
+ ["git", *args], cwd=repo_path, env=os.environ, encoding="utf-8"
736
+ )
737
+ for entry in out.split("\0"):
738
+ if not entry:
739
+ continue
740
+ meta, path = entry.split("\t", 1)
741
+ if meta.split(" ")[1] == "blob" and "\n" not in path and "\r" not in path:
742
+ patterns.append(f"/{_escape_pattern(path)}")
743
+ return patterns
744
+
745
+
746
+ def _cone_directories(patterns: List[str]) -> Optional[List[str]]:
747
+ directories = []
748
+ for pattern in patterns:
749
+ body = pattern[1:].rstrip("/")
750
+ if not pattern.startswith("/") or not body or _PATTERN_METACHARS_RE.search(body):
751
+ return None
752
+ directories.append(body)
753
+ return directories
754
+
755
+
756
+ class SparsePlan(NamedTuple):
757
+ """The one `git sparse-checkout` command a checkout needs, if any."""
758
+
759
+ cone: bool
760
+ op: Optional[Literal["sparse_set", "sparse_add", "sparse_convert", "sparse_disable"]]
761
+ args: List[str]
762
+ entries: List[str]
763
+ message: str
764
+
765
+
766
+ def _plan_sparse_checkout(
767
+ repo_path: str,
768
+ has_repo: bool,
769
+ is_sparse: bool,
770
+ sparse_profile: Optional[str],
771
+ sparse_patterns: Optional[List[str]],
772
+ rev: str,
773
+ ) -> SparsePlan:
774
+ profile = sparse_profile or ""
775
+ if not sparse_patterns:
776
+ if is_sparse:
777
+ return SparsePlan(
778
+ False,
779
+ "sparse_disable",
780
+ ["git", "sparse-checkout", "disable"],
781
+ [],
782
+ "widening the sparse checkout to the full tree",
783
+ )
784
+ return SparsePlan(False, None, [], [], "")
785
+
786
+ if has_repo and not is_sparse:
787
+ message = (
788
+ f"the cached checkout is a full checkout, sparse profile "
789
+ f"{profile} is not applied"
790
+ )
791
+ return SparsePlan(False, None, [], [], message)
792
+
793
+ cone_directories = _cone_directories(sparse_patterns)
794
+ if not has_repo:
795
+ if cone_directories is not None:
796
+ args = [
797
+ "git",
798
+ "sparse-checkout",
799
+ "set",
800
+ "--cone",
801
+ "--sparse-index",
802
+ "--stdin",
803
+ ]
804
+ cone, entries = True, cone_directories
805
+ else:
806
+ args = ["git", "sparse-checkout", "set", "--no-cone", "--stdin"]
807
+ cone, entries = False, sparse_patterns
808
+ op = "sparse_set"
809
+ else:
810
+ is_cone = _git_config(repo_path, "core.sparseCheckoutCone") == "true"
811
+ current = _git_lines(repo_path, "sparse-checkout", "list")
812
+ # Newly checked out patterns are compatible with a `cone` sparse
813
+ # checkout and the existing sparse checkout is `cone`: simply add the
814
+ # new directories to it.
815
+ if cone_directories is not None and is_cone:
816
+ args = ["git", "sparse-checkout", "add", "--stdin"]
817
+ cone, op = True, "sparse_add"
818
+ entries = [d for d in cone_directories if d not in set(current)]
819
+ # Newly checked out patterns are not compatible with a `cone` sparse
820
+ # checkout and the existing sparse checkout is `cone`: perform a new
821
+ # sparse checkout with `cone` disabled, including the existing
822
+ # directories, the parent directory files that `cone` mode included
823
+ # implicitly, and the new patterns.
824
+ elif is_cone:
825
+ args = [
826
+ "git",
827
+ "sparse-checkout",
828
+ "set",
829
+ "--no-cone",
830
+ "--no-sparse-index",
831
+ "--stdin",
832
+ ]
833
+ cone, op = False, "sparse_convert"
834
+ entries = list(
835
+ dict.fromkeys(
836
+ [f"/{_escape_pattern(d)}" for d in current]
837
+ + _cone_ancestor_files(repo_path, rev, current)
838
+ + sparse_patterns
839
+ )
840
+ )
841
+ # Newly checked out patterns are not compatible with a `cone` sparse
842
+ # checkout and the existing sparse checkout is not `cone`: simply add the
843
+ # new patterns to it.
844
+ else:
845
+ args = ["git", "sparse-checkout", "add", "--stdin"]
846
+ cone, op = False, "sparse_add"
847
+ entries = [p for p in sparse_patterns if p not in set(current)]
848
+ if not entries:
849
+ return SparsePlan(
850
+ cone, None, [], [], f"sparse profile {profile} already applied"
851
+ )
852
+
853
+ message = f"{op}: sparse profile {profile}, {len(entries)} entries"
854
+ return SparsePlan(cone, op, args, entries, message)
855
+
856
+
676
857
  def shortref(ref: str) -> str:
677
858
  """Normalize a git ref to its short form.
678
859
 
@@ -696,6 +877,8 @@ def git_checkout(
696
877
  ssh_key_file: Optional[Path],
697
878
  ssh_known_hosts_file: Optional[Path],
698
879
  shallow: bool = False,
880
+ sparse_profile: Optional[str] = None,
881
+ sparse_patterns: Optional[List[str]] = None,
699
882
  ):
700
883
  assert head_ref or head_rev
701
884
 
@@ -725,20 +908,32 @@ def git_checkout(
725
908
  )
726
909
 
727
910
  has_repo = os.path.exists(destination_path)
728
- perf = PerfRecorder("vcs")
729
- with perf.record("overall", "overall_pull" if has_repo else "overall_clone"):
730
- # Bypass Git's "safe directory" feature as the destination could be
731
- # coming from a cache and therefore cloned by a different user.
732
- args = [
733
- "git",
734
- "config",
735
- "--global",
736
- "--add",
737
- "safe.directory",
738
- Path(destination_path).as_posix(),
739
- ]
740
- retry_required_command(b"vcs", args, extra_env=env)
741
911
 
912
+ # Bypass Git's "safe directory" feature as the destination could be
913
+ # coming from a cache and therefore cloned by a different user.
914
+ args = [
915
+ "git",
916
+ "config",
917
+ "--global",
918
+ "--add",
919
+ "safe.directory",
920
+ Path(destination_path).as_posix(),
921
+ ]
922
+ retry_required_command(b"vcs", args, extra_env=env)
923
+
924
+ workers = os.environ.get("RUN_TASK_GIT_CHECKOUT_WORKERS", "0")
925
+ args = ["git", "config", "--global", "checkout.workers", workers]
926
+ retry_required_command(b"vcs", args, extra_env=env)
927
+
928
+ is_sparse = (
929
+ has_repo and _git_config(destination_path, "core.sparseCheckout") == "true"
930
+ )
931
+ sparse = bool(sparse_patterns) and (not has_repo or is_sparse)
932
+
933
+ action = "pull" if has_repo else "clone"
934
+ mode = "sparsecheckout" if sparse else "fullcheckout"
935
+ perf = PerfRecorder("vcs")
936
+ with perf.record("overall", f"overall_{action}", f"overall_{action}_{mode}"):
742
937
  if not has_repo:
743
938
  # Repository doesn't already exist, needs to be cloned
744
939
  args = [
@@ -747,7 +942,13 @@ def git_checkout(
747
942
  ]
748
943
 
749
944
  if shallow:
750
- args.extend(["--depth=1", "--no-checkout"])
945
+ args.append("--depth=1")
946
+
947
+ if sparse:
948
+ args.append("--filter=blob:none")
949
+
950
+ if shallow or sparse:
951
+ args.append("--no-checkout")
751
952
 
752
953
  args.extend(
753
954
  [
@@ -758,6 +959,11 @@ def git_checkout(
758
959
 
759
960
  with perf.record("clone"):
760
961
  retry_required_command(b"vcs", args, extra_env=env)
962
+ else:
963
+ # Fetches go through origin by name, so a cached clone has to
964
+ # point origin at the repository this task wants.
965
+ args = ["git", "remote", "set-url", "origin", base_repo or head_repo]
966
+ run_required_command(b"vcs", args, cwd=destination_path)
761
967
 
762
968
  # For Github based repos, base_rev often doesn't refer to an ancestor of
763
969
  # head_rev simply due to Github not providing that information in their
@@ -786,16 +992,42 @@ def git_checkout(
786
992
  # shallow clone, head_rev needs to be fetched independently regardless.
787
993
  targets.append(head_rev)
788
994
 
995
+ # A partial clone keeps its blob filter on the origin remote, and a
996
+ # fetch only inherits the filter when it names that remote. Fetching
997
+ # the same repository by URL would download every blob of the commit.
998
+ remote = "origin" if head_repo == (base_repo or head_repo) else head_repo
999
+
789
1000
  with perf.record("pull"):
790
1001
  git_fetch(
791
1002
  destination_path,
792
1003
  *targets,
793
- remote=head_repo,
1004
+ remote=remote,
794
1005
  tags=tags,
795
1006
  shallow=shallow,
796
1007
  env=env,
797
1008
  )
798
1009
 
1010
+ # `git fetch` set `FETCH_HEAD` reference to the last commit of the desired branch
1011
+ rev = head_rev if head_rev else "FETCH_HEAD"
1012
+
1013
+ plan = _plan_sparse_checkout(
1014
+ destination_path, has_repo, is_sparse, sparse_profile, sparse_patterns, rev
1015
+ )
1016
+ if sparse:
1017
+ perf.extra_options.append("cone" if plan.cone else "no-cone")
1018
+ if sparse_profile:
1019
+ perf.extra_options.append(os.path.basename(sparse_profile))
1020
+ if plan.message:
1021
+ print_line(b"vcs", f"{plan.message}\n".encode("utf-8"))
1022
+ if plan.op:
1023
+ stdin_data = None
1024
+ if plan.entries:
1025
+ stdin_data = ("\n".join(plan.entries) + "\n").encode("utf-8")
1026
+ with perf.record(plan.op):
1027
+ run_required_command(
1028
+ b"vcs", plan.args, cwd=destination_path, stdin_data=stdin_data
1029
+ )
1030
+
799
1031
  args = [
800
1032
  "git",
801
1033
  "checkout",
@@ -805,8 +1037,7 @@ def git_checkout(
805
1037
  if head_ref:
806
1038
  args.extend(["-B", shortref(head_ref)])
807
1039
 
808
- # `git fetch` set `FETCH_HEAD` reference to the last commit of the desired branch
809
- args.append(head_rev if head_rev else "FETCH_HEAD")
1040
+ args.append(rev)
810
1041
 
811
1042
  with perf.record("update"):
812
1043
  run_required_command(b"vcs", args, cwd=destination_path)
@@ -893,6 +1124,7 @@ def hg_checkout(
893
1124
  store_path: str,
894
1125
  branch: Optional[str],
895
1126
  revision: Optional[str],
1127
+ sparse_profile: Optional[str] = None,
896
1128
  ):
897
1129
  if IS_MACOSX or IS_POSIX:
898
1130
  hg_bin = "hg"
@@ -916,6 +1148,9 @@ def hg_checkout(
916
1148
  if base_repo:
917
1149
  args.extend(["--upstream", base_repo])
918
1150
 
1151
+ if sparse_profile:
1152
+ args.extend(["--sparseprofile", sparse_profile])
1153
+
919
1154
  # Specify method to checkout a revision. This defaults to revisions as
920
1155
  # SHA-1 strings, but also supports symbolic revisions like `tip` via the
921
1156
  # branch flag.
@@ -983,13 +1218,35 @@ def add_vcs_arguments(parser, project, name):
983
1218
  action="store_true",
984
1219
  help=f"Use shallow clone for {name}",
985
1220
  )
1221
+ parser.add_argument(
1222
+ f"--{project}-sparse-profile",
1223
+ help=f"Path to sparse profile for {name} checkout. For git checkouts "
1224
+ f"the patterns come from the {project.upper()}_SPARSE_PATTERNS "
1225
+ "environment variable, a JSON list of git sparse-checkout patterns.",
1226
+ )
986
1227
 
987
1228
 
988
1229
  def collect_vcs_options(args, project, name):
989
1230
  checkout = getattr(args, f"{project}_checkout")
990
1231
  shallow_clone = getattr(args, f"{project}_shallow_clone")
1232
+ sparse_profile = getattr(args, f"{project}_sparse_profile", None)
991
1233
 
992
1234
  env_prefix = project.upper()
1235
+ sparse_patterns = None
1236
+ if f"{env_prefix}_SPARSE_PATTERNS" in os.environ:
1237
+ try:
1238
+ sparse_patterns = json.loads(os.environ[f"{env_prefix}_SPARSE_PATTERNS"])
1239
+ except ValueError as e:
1240
+ raise RuntimeError(
1241
+ f"{env_prefix}_SPARSE_PATTERNS is not a JSON list of patterns: {e}"
1242
+ ) from None
1243
+ if not isinstance(sparse_patterns, list) or not all(
1244
+ isinstance(p, str) and p.strip() for p in sparse_patterns
1245
+ ):
1246
+ raise RuntimeError(
1247
+ f"{env_prefix}_SPARSE_PATTERNS must be a JSON list of non-empty "
1248
+ "pattern strings"
1249
+ )
993
1250
 
994
1251
  repo_type = os.environ.get(f"{env_prefix}_REPOSITORY_TYPE")
995
1252
  base_repo = os.environ.get(f"{env_prefix}_BASE_REPOSITORY")
@@ -1032,6 +1289,8 @@ def collect_vcs_options(args, project, name):
1032
1289
  "ssh-secret-name": private_key_secret,
1033
1290
  "pip-requirements": pip_requirements,
1034
1291
  "shallow-clone": shallow_clone,
1292
+ "sparse-profile": sparse_profile,
1293
+ "sparse-patterns": sparse_patterns,
1035
1294
  }
1036
1295
 
1037
1296
 
@@ -1040,6 +1299,10 @@ def vcs_checkout_from_args(options):
1040
1299
  if options["head-ref"] and not options["head-rev"]:
1041
1300
  print("task should be defined in terms of non-symbolic revision")
1042
1301
  sys.exit(1)
1302
+ if options["sparse-profile"] or options["sparse-patterns"] is not None:
1303
+ raise RuntimeError(
1304
+ f"--{options['project']}-sparse-profile needs --{options['project']}-checkout"
1305
+ )
1043
1306
  return
1044
1307
 
1045
1308
  head_ref = options["head-ref"]
@@ -1071,6 +1334,17 @@ def vcs_checkout_from_args(options):
1071
1334
  if not head_ref:
1072
1335
  print("Providing a ref will improve the performance of this checkout")
1073
1336
 
1337
+ if bool(options["sparse-profile"]) != (options["sparse-patterns"] is not None):
1338
+ raise RuntimeError(
1339
+ f"--{options['project']}-sparse-profile and the "
1340
+ f"{options['env-prefix']}_SPARSE_PATTERNS environment variable "
1341
+ "must be given together for a git checkout"
1342
+ )
1343
+ if options["sparse-patterns"] is not None and not options["sparse-patterns"]:
1344
+ raise RuntimeError(
1345
+ f"{options['env-prefix']}_SPARSE_PATTERNS holds no patterns"
1346
+ )
1347
+
1074
1348
  revision = git_checkout(
1075
1349
  options["checkout"],
1076
1350
  options["head-repo"],
@@ -1081,6 +1355,8 @@ def vcs_checkout_from_args(options):
1081
1355
  ssh_key_file,
1082
1356
  ssh_known_hosts_file,
1083
1357
  shallow=options.get("shallow-clone", False),
1358
+ sparse_profile=options["sparse-profile"],
1359
+ sparse_patterns=options["sparse-patterns"],
1084
1360
  )
1085
1361
  elif options["repo-type"] == "hg":
1086
1362
  revision = hg_checkout(
@@ -1090,6 +1366,7 @@ def vcs_checkout_from_args(options):
1090
1366
  options["store-path"],
1091
1367
  head_ref,
1092
1368
  head_rev,
1369
+ sparse_profile=options["sparse-profile"],
1093
1370
  )
1094
1371
  else:
1095
1372
  raise RuntimeError('Type of VCS must be either "git" or "hg"')
@@ -870,6 +870,8 @@ def add_generic_index_routes(config, task):
870
870
 
871
871
  @transforms.add
872
872
  def process_treeherder_metadata(config, tasks):
873
+ group_names = config.graph_config.get("treeherder", {}).get("group-names", {})
874
+
873
875
  for task in tasks:
874
876
  routes = task.get("routes", [])
875
877
  extra = task.get("extra", {})
@@ -891,7 +893,6 @@ def process_treeherder_metadata(config, tasks):
891
893
  treeherder["machine"] = {"platform": machine_platform}
892
894
  treeherder["collection"] = {collection: True}
893
895
 
894
- group_names = config.graph_config["treeherder"]["group-names"]
895
896
  groupSymbol, symbol = split_symbol(merged_th["symbol"])
896
897
  if groupSymbol != "?":
897
898
  treeherder["groupSymbol"] = groupSymbol