pivot 0.3.2__tar.gz → 0.3.3__tar.gz

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.
Files changed (107) hide show
  1. {pivot-0.3.2 → pivot-0.3.3}/PKG-INFO +1 -1
  2. {pivot-0.3.2 → pivot-0.3.3}/pyproject.toml +1 -1
  3. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/cli/checkout.py +9 -4
  4. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/cli/track.py +6 -5
  5. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/exceptions.py +21 -0
  6. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/storage/cache.py +111 -11
  7. {pivot-0.3.2 → pivot-0.3.3}/LICENSE +0 -0
  8. {pivot-0.3.2 → pivot-0.3.3}/README.md +0 -0
  9. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/__init__.py +0 -0
  10. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/ast_utils.py +0 -0
  11. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/cli/AGENTS.md +0 -0
  12. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/cli/CLAUDE.md +0 -0
  13. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/cli/__init__.py +0 -0
  14. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/cli/_run_common.py +0 -0
  15. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/cli/check_ignore.py +0 -0
  16. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/cli/commit.py +0 -0
  17. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/cli/completion.py +0 -0
  18. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/cli/config.py +0 -0
  19. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/cli/console.py +0 -0
  20. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/cli/dag.py +0 -0
  21. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/cli/data.py +0 -0
  22. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/cli/decorators.py +0 -0
  23. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/cli/doctor.py +0 -0
  24. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/cli/errors.py +0 -0
  25. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/cli/fingerprint.py +0 -0
  26. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/cli/helpers.py +0 -0
  27. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/cli/history.py +0 -0
  28. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/cli/import_cmd.py +0 -0
  29. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/cli/init.py +0 -0
  30. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/cli/list.py +0 -0
  31. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/cli/metrics.py +0 -0
  32. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/cli/params.py +0 -0
  33. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/cli/plots.py +0 -0
  34. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/cli/remote.py +0 -0
  35. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/cli/repro.py +0 -0
  36. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/cli/run.py +0 -0
  37. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/cli/status.py +0 -0
  38. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/cli/targets.py +0 -0
  39. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/cli/update.py +0 -0
  40. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/cli/verify.py +0 -0
  41. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/compose.py +0 -0
  42. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/config/__init__.py +0 -0
  43. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/config/io.py +0 -0
  44. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/config/models.py +0 -0
  45. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/dag/__init__.py +0 -0
  46. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/dag/render.py +0 -0
  47. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/decorators.py +0 -0
  48. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/discovery.py +0 -0
  49. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/engine/__init__.py +0 -0
  50. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/engine/agent_rpc.py +0 -0
  51. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/engine/engine.py +0 -0
  52. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/engine/graph.py +0 -0
  53. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/engine/scheduler.py +0 -0
  54. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/engine/sinks.py +0 -0
  55. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/engine/sources.py +0 -0
  56. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/engine/types.py +0 -0
  57. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/engine/watch.py +0 -0
  58. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/engine/worker_pool.py +0 -0
  59. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/executor/AGENTS.md +0 -0
  60. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/executor/CLAUDE.md +0 -0
  61. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/executor/__init__.py +0 -0
  62. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/executor/commit.py +0 -0
  63. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/executor/core.py +0 -0
  64. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/executor/worker.py +0 -0
  65. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/explain.py +0 -0
  66. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/fingerprint.py +0 -0
  67. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/git.py +0 -0
  68. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/ignore.py +0 -0
  69. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/import_artifact.py +0 -0
  70. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/loaders.py +0 -0
  71. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/metrics.py +0 -0
  72. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/names.py +0 -0
  73. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/outputs.py +0 -0
  74. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/parameters.py +0 -0
  75. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/path_policy.py +0 -0
  76. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/project.py +0 -0
  77. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/py.typed +0 -0
  78. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/registry.py +0 -0
  79. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/remote/AGENTS.md +0 -0
  80. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/remote/__init__.py +0 -0
  81. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/remote/config.py +0 -0
  82. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/remote/git_archive.py +0 -0
  83. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/remote/github.py +0 -0
  84. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/remote/storage.py +0 -0
  85. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/remote/sync.py +0 -0
  86. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/run_history.py +0 -0
  87. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/show/__init__.py +0 -0
  88. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/show/common.py +0 -0
  89. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/show/data.py +0 -0
  90. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/show/metrics.py +0 -0
  91. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/show/params.py +0 -0
  92. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/show/plots.py +0 -0
  93. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/skip.py +0 -0
  94. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/stage_def.py +0 -0
  95. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/status.py +0 -0
  96. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/storage/AGENTS.md +0 -0
  97. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/storage/CLAUDE.md +0 -0
  98. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/storage/__init__.py +0 -0
  99. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/storage/artifact_lock.py +0 -0
  100. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/storage/lock.py +0 -0
  101. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/storage/presentation.py +0 -0
  102. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/storage/restore.py +0 -0
  103. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/storage/state.py +0 -0
  104. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/storage/store.py +0 -0
  105. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/storage/track.py +0 -0
  106. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/types.py +0 -0
  107. {pivot-0.3.2 → pivot-0.3.3}/src/pivot/yaml_config.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pivot
3
- Version: 0.3.2
3
+ Version: 0.3.3
4
4
  Summary: High-performance Python pipeline tool with automatic code change detection
5
5
  Author: Sami Jawhar
6
6
  Author-email: Sami Jawhar <sami@metr.org>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pivot"
3
- version = "0.3.2"
3
+ version = "0.3.3"
4
4
  description = "High-performance Python pipeline tool with automatic code change detection"
5
5
  requires-python = ">=3.13,<3.14"
6
6
  readme = "README.md"
@@ -8,9 +8,10 @@ from typing import TYPE_CHECKING, Literal
8
8
 
9
9
  import click
10
10
 
11
- from pivot import config, project, registry, types
11
+ from pivot import config, exceptions, project, registry, types
12
12
  from pivot.cli import completion
13
13
  from pivot.cli import decorators as cli_decorators
14
+ from pivot.cli import errors as cli_errors
14
15
  from pivot.cli import helpers as cli_helpers
15
16
  from pivot.cli import targets as cli_targets
16
17
  from pivot.storage import cache, lock, state, track
@@ -229,9 +230,13 @@ async def _checkout_files_async(
229
230
  for abs_path_str, output_hash in files.items():
230
231
  _ = tg.create_task(restore_one(abs_path_str, output_hash))
231
232
  except* Exception as eg:
232
- # Convert unexpected exceptions to friendly error message
233
- errors = [str(e) for e in eg.exceptions]
234
- raise click.ClickException("\n".join(errors)) from None
233
+ # Convert unexpected exceptions to a friendly error message, rendering a PivotError's
234
+ # actionable suggestion the same way the top-level CLI error handler does (cli/errors.py).
235
+ messages = [
236
+ cli_errors.format_pivot_error(e) if isinstance(e, exceptions.PivotError) else str(e)
237
+ for e in eg.exceptions
238
+ ]
239
+ raise click.ClickException("\n".join(messages)) from None
235
240
 
236
241
  # Re-raise immediate failures (all of them, aggregated)
237
242
  if immediate_errors:
@@ -160,12 +160,14 @@ def _track_single_path(
160
160
  total_size = sum(e["size"] for e in manifest)
161
161
  num_files = len(manifest)
162
162
 
163
- # Save each file to cache
163
+ # copy_to_cache is idempotent and already checks existence itself; letting it see
164
+ # every entry (not just ones missing from the cache) is what lets it republish an
165
+ # object `pivot track --force` finds cached non-executable by an older pivot or a
166
+ # cold `pivot pull`, when this entry's bytes should be executable.
164
167
  for entry in manifest:
165
168
  file_path = path / entry["relpath"]
166
169
  file_cache_path = cache.get_cache_path(cache_dir, entry["hash"])
167
- if not file_cache_path.exists():
168
- cache.copy_to_cache(file_path, file_cache_path)
170
+ cache.copy_to_cache(file_path, file_cache_path, executable=entry["isexec"])
169
171
 
170
172
  pvt_data: track_mod.PvtData = {
171
173
  "path": path.name,
@@ -178,8 +180,7 @@ def _track_single_path(
178
180
  file_hash, _ = cache.hash_file(path)
179
181
  file_size = path.stat().st_size
180
182
  file_cache_path = cache.get_cache_path(cache_dir, file_hash)
181
- if not file_cache_path.exists():
182
- cache.copy_to_cache(path, file_cache_path)
183
+ cache.copy_to_cache(path, file_cache_path)
183
184
 
184
185
  pvt_data = {
185
186
  "path": path.name,
@@ -214,6 +214,27 @@ class UncachedIncrementalOutputError(CacheError):
214
214
  """Raised when an incremental output exists without a cache entry."""
215
215
 
216
216
 
217
+ class CacheModeCollisionError(CacheError):
218
+ """A HARDLINK/SYMLINK checkout shares the cache object's own permissions and found it
219
+ already recorded with the wrong executable bit, with no COPY fallback available.
220
+
221
+ This happens when two files with identical content disagree on their executable bit (the
222
+ cache key is content-only) or when a cache object was populated by a path that does not
223
+ know a file's intended executable bit (e.g. `pivot pull`, which downloads by hash alone).
224
+ """
225
+
226
+ @override
227
+ def get_suggestion(self) -> str:
228
+ return (
229
+ "Add CheckoutMode.COPY to checkout_modes (cache: checkout_mode: [..., copy] in "
230
+ ".pivot/config.yaml) so checkout can fall back to a private, correctly-permissioned "
231
+ "copy instead of sharing the cache object's own permissions; or, if you have the "
232
+ "source bytes in hand, re-running what produced them (`pivot repro --force "
233
+ "<stage>`, `pivot track --force <path>`) republishes the cache object with the "
234
+ "right bit"
235
+ )
236
+
237
+
217
238
  class ParamsError(PivotError):
218
239
  """Raised when parameter validation or loading fails."""
219
240
 
@@ -447,9 +447,21 @@ def _get_symlink_cache_hash(path: pathlib.Path, cache_dir: pathlib.Path) -> str
447
447
  return None # Includes ELOOP for circular symlinks
448
448
 
449
449
 
450
- def copy_to_cache(src: pathlib.Path, cache_path: pathlib.Path) -> None:
451
- """Atomically copy file to cache with read-only permissions."""
452
- if cache_path.exists():
450
+ def copy_to_cache(src: pathlib.Path, cache_path: pathlib.Path, *, executable: bool = False) -> None:
451
+ """Atomically copy file to cache with read-only permissions.
452
+
453
+ ``executable`` records the source's executable bit on the cache object itself (0o555
454
+ instead of 0o444): a HARDLINK or SYMLINK checkout has no mode of its own, so it reads
455
+ straight through to whatever mode the cache object carries, and only COPY checkout
456
+ re-chmods afterwards (see ``_stage_checkout``).
457
+
458
+ An existing object missing the executable bit is republished (a fresh temp file atomically
459
+ replacing the old one, never a chmod in place: existing hardlinks keep the old inode and
460
+ its mode) when ``executable`` is True, so an object an older pivot or a remote pull cached
461
+ non-executable can still be repaired by a caller who has the right bytes in hand. An
462
+ object already executable, or a non-executable request, is left alone.
463
+ """
464
+ if cache_path.exists() and (not executable or _cache_object_isexec(cache_path)):
453
465
  return
454
466
 
455
467
  cache_path.parent.mkdir(parents=True, exist_ok=True)
@@ -458,7 +470,7 @@ def copy_to_cache(src: pathlib.Path, cache_path: pathlib.Path) -> None:
458
470
  try:
459
471
  os.close(fd)
460
472
  shutil.copy2(src, tmp_path)
461
- os.chmod(tmp_path, 0o444)
473
+ os.chmod(tmp_path, 0o555 if executable else 0o444)
462
474
  tmp.replace(cache_path)
463
475
  except BaseException:
464
476
  tmp.unlink(missing_ok=True)
@@ -473,7 +485,7 @@ def _stage_checkout(
473
485
  cache_path: pathlib.Path,
474
486
  checkout_mode: CheckoutMode,
475
487
  *,
476
- executable: bool,
488
+ executable: bool | None,
477
489
  ) -> None:
478
490
  """Create the link or copy of cache_path at tmp_path."""
479
491
  if checkout_mode == CheckoutMode.SYMLINK:
@@ -490,7 +502,7 @@ def _checkout_from_cache(
490
502
  cache_path: pathlib.Path,
491
503
  checkout_mode: CheckoutMode,
492
504
  *,
493
- executable: bool = False,
505
+ executable: bool | None = None,
494
506
  staging_path: pathlib.Path | None = None,
495
507
  ) -> None:
496
508
  """Create link or copy at path atomically, so readers never see a partial file.
@@ -533,33 +545,105 @@ def _checkout_from_cache(
533
545
  tmp_path.unlink(missing_ok=True)
534
546
 
535
547
 
548
+ def _cache_object_isexec(cache_path: pathlib.Path) -> bool | None:
549
+ """Whether the cache object at cache_path is stored executable, or None if absent.
550
+
551
+ HARDLINK and SYMLINK checkout have no mode of their own: the workspace file shares the
552
+ cache object's permissions directly, so they can only ever produce whatever executable
553
+ bit the cache object itself carries.
554
+ """
555
+ try:
556
+ return bool(cache_path.stat().st_mode & stat.S_IXUSR)
557
+ except FileNotFoundError:
558
+ return None
559
+
560
+
561
+ def _repair_tree_dir_isexec(cache_dir_path: pathlib.Path, manifest: list[DirManifestEntry]) -> None:
562
+ """Chmod any exec-marked file in a whole-directory SYMLINK cache tree that is missing
563
+ its executable bit, to 0o555.
564
+
565
+ A tree an older pivot built (every file chmod'd 0o444 regardless of isexec) keeps being
566
+ reused across an upgrade: its tree hash already incorporates isexec, so it never changes
567
+ and neither _store_directory's ``not cache_dir_path.exists()`` branch nor the
568
+ symlink_fast_path restore would otherwise ever revisit it. Chmod'ing in place is safe
569
+ here, unlike the single-file cache: a tree directory is a private ``copytree`` copy with
570
+ no other hardlinks sharing its files.
571
+
572
+ ``manifest`` may come straight from a lock file on the restore path, so each relpath is
573
+ checked the same way the file-by-file restore branch already does: a manifest claiming
574
+ ``../../../outside/file`` must not chmod a file outside the tree.
575
+ """
576
+ resolved_tree = cache_dir_path.resolve()
577
+ for entry in manifest:
578
+ if not entry["isexec"]:
579
+ continue
580
+ f = cache_dir_path / entry["relpath"]
581
+ if not f.resolve().is_relative_to(resolved_tree):
582
+ raise exceptions.SecurityValidationError(
583
+ f"Manifest contains path traversal: {entry['relpath']!r}"
584
+ )
585
+ if _cache_object_isexec(f) is False:
586
+ os.chmod(f, 0o555)
587
+
588
+
536
589
  def _checkout_with_fallback(
537
590
  path: pathlib.Path,
538
591
  cache_path: pathlib.Path,
539
592
  checkout_modes: list[CheckoutMode],
540
593
  *,
541
- executable: bool = False,
594
+ executable: bool | None = None,
542
595
  staging_path: pathlib.Path | None = None,
543
596
  keep_existing_copy: bool = False,
597
+ display_path: pathlib.Path | None = None,
544
598
  ) -> None:
545
599
  """Try each link mode in order until one succeeds.
546
600
 
547
601
  With ``keep_existing_copy``, reaching COPY while ``path`` still exists leaves it in place:
548
602
  the caller guarantees it already holds the cached bytes. A failed link attempt clears
549
603
  ``path`` first, so COPY then copies from the cache as usual.
604
+
605
+ ``executable`` is None for a single-file output: ``FileHash`` has no isexec field, so a
606
+ single file's mode is never part of any hash and there is no wrong permission to guard
607
+ against. For a directory entry (``executable`` is a bool), a HARDLINK or SYMLINK whose
608
+ cache object's recorded executable bit does not match it is skipped rather than attempted:
609
+ two files with identical content can disagree on the executable bit (the cache key is
610
+ content-only), and a cache object a remote pull populated by hash alone carries no
611
+ executable bit at all. Either way the shared object cannot represent two permissions at
612
+ once, so checkout falls through to the next mode; only COPY makes a private copy and can
613
+ always re-chmod it correctly.
614
+
615
+ ``display_path`` names the file in a raised error when ``path`` is a restore-only staging
616
+ location (a caller checking out into a scratch directory before an atomic swap); it
617
+ defaults to ``path`` for every other caller.
550
618
  """
551
619
  if not checkout_modes:
552
620
  raise ValueError("checkout_modes cannot be empty")
621
+ display = display_path if display_path is not None else path
553
622
  for i, mode in enumerate(checkout_modes):
623
+ is_last = i == len(checkout_modes) - 1
554
624
  if keep_existing_copy and mode == CheckoutMode.COPY and path.exists():
555
625
  return
626
+ if executable is not None and mode in (CheckoutMode.HARDLINK, CheckoutMode.SYMLINK):
627
+ cached_isexec = _cache_object_isexec(cache_path)
628
+ if cached_isexec is not None and cached_isexec != executable:
629
+ if is_last:
630
+ cached_state = "executable" if cached_isexec else "non-executable"
631
+ raise exceptions.CacheModeCollisionError(
632
+ f"Cache object {cache_path} is recorded {cached_state}, but {display} "
633
+ + f"needs isexec={executable}."
634
+ )
635
+ logger.debug(
636
+ f"Cache object {cache_path} is executable={cached_isexec}, "
637
+ + f"need {executable}; skipping {mode.value}"
638
+ )
639
+ continue
556
640
  try:
557
641
  _checkout_from_cache(
558
642
  path, cache_path, mode, executable=executable, staging_path=staging_path
559
643
  )
560
644
  return
561
645
  except OSError as e:
562
- if e.errno not in FALLBACK_ERRNO or i == len(checkout_modes) - 1:
646
+ if e.errno not in FALLBACK_ERRNO or is_last:
563
647
  raise
564
648
  logger.debug(f"Checkout mode {mode.value} failed ({e}), trying next mode")
565
649
 
@@ -716,7 +800,7 @@ def _store_directory(
716
800
  for entry in manifest:
717
801
  file_path = path / entry["relpath"]
718
802
  cache_path = get_cache_path(cache_dir, entry["hash"])
719
- copy_to_cache(file_path, cache_path)
803
+ copy_to_cache(file_path, cache_path, executable=entry["isexec"])
720
804
 
721
805
  with restore_lock(path, state_dir):
722
806
  if checkout_modes and checkout_modes[0] == CheckoutMode.SYMLINK:
@@ -725,12 +809,20 @@ def _store_directory(
725
809
  if not cache_dir_path.exists():
726
810
  cache_dir_path.parent.mkdir(parents=True, exist_ok=True)
727
811
  shutil.copytree(path, cache_dir_path)
812
+ isexec_by_relpath = {entry["relpath"]: entry["isexec"] for entry in manifest}
728
813
  for f in cache_dir_path.rglob("*"):
729
814
  # Skip symlinks to avoid changing permissions on target files outside cache
730
815
  if f.is_file() and not f.is_symlink():
731
- os.chmod(f, 0o444)
816
+ relpath = str(f.relative_to(cache_dir_path))
817
+ executable = isexec_by_relpath.get(relpath, False)
818
+ os.chmod(f, 0o555 if executable else 0o444)
732
819
  os.chmod(cache_dir_path, 0o555)
733
820
 
821
+ # A tree an older pivot built keeps its 0o444-everywhere files forever otherwise:
822
+ # the tree hash already incorporates isexec, so this branch never revisits an
823
+ # existing tree directory above.
824
+ _repair_tree_dir_isexec(cache_dir_path, manifest)
825
+
734
826
  _clear_path(path)
735
827
  path.symlink_to(cache_dir_path.resolve())
736
828
  else:
@@ -902,6 +994,10 @@ def _restore_directory_from_cache(
902
994
  restore_prefix = f"{_RESTORE_TEMP_PREFIX}{target_token}."
903
995
 
904
996
  if symlink_fast_path:
997
+ # Same repair as _store_directory: a tree an older pivot built keeps its
998
+ # 0o444-everywhere files forever otherwise, since the tree hash already
999
+ # incorporates isexec and this path never rebuilds an existing tree directory.
1000
+ _repair_tree_dir_isexec(cache_dir_path, output_hash["manifest"])
905
1001
  temp_symlink = path.parent / f"{restore_prefix}symlink_{secrets.token_hex(4)}"
906
1002
  try:
907
1003
  temp_symlink.symlink_to(cache_dir_path.resolve())
@@ -931,7 +1027,11 @@ def _restore_directory_from_cache(
931
1027
  )
932
1028
  file_path.parent.mkdir(parents=True, exist_ok=True)
933
1029
  _checkout_with_fallback(
934
- file_path, file_cache_path, checkout_modes, executable=entry["isexec"]
1030
+ file_path,
1031
+ file_cache_path,
1032
+ checkout_modes,
1033
+ executable=entry["isexec"],
1034
+ display_path=path / entry["relpath"],
935
1035
  )
936
1036
  if file_count % 100 == 0:
937
1037
  try:
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes