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.
- {pivot-0.3.2 → pivot-0.3.3}/PKG-INFO +1 -1
- {pivot-0.3.2 → pivot-0.3.3}/pyproject.toml +1 -1
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/cli/checkout.py +9 -4
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/cli/track.py +6 -5
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/exceptions.py +21 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/storage/cache.py +111 -11
- {pivot-0.3.2 → pivot-0.3.3}/LICENSE +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/README.md +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/__init__.py +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/ast_utils.py +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/cli/AGENTS.md +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/cli/CLAUDE.md +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/cli/__init__.py +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/cli/_run_common.py +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/cli/check_ignore.py +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/cli/commit.py +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/cli/completion.py +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/cli/config.py +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/cli/console.py +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/cli/dag.py +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/cli/data.py +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/cli/decorators.py +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/cli/doctor.py +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/cli/errors.py +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/cli/fingerprint.py +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/cli/helpers.py +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/cli/history.py +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/cli/import_cmd.py +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/cli/init.py +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/cli/list.py +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/cli/metrics.py +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/cli/params.py +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/cli/plots.py +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/cli/remote.py +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/cli/repro.py +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/cli/run.py +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/cli/status.py +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/cli/targets.py +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/cli/update.py +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/cli/verify.py +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/compose.py +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/config/__init__.py +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/config/io.py +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/config/models.py +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/dag/__init__.py +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/dag/render.py +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/decorators.py +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/discovery.py +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/engine/__init__.py +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/engine/agent_rpc.py +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/engine/engine.py +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/engine/graph.py +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/engine/scheduler.py +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/engine/sinks.py +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/engine/sources.py +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/engine/types.py +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/engine/watch.py +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/engine/worker_pool.py +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/executor/AGENTS.md +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/executor/CLAUDE.md +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/executor/__init__.py +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/executor/commit.py +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/executor/core.py +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/executor/worker.py +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/explain.py +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/fingerprint.py +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/git.py +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/ignore.py +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/import_artifact.py +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/loaders.py +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/metrics.py +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/names.py +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/outputs.py +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/parameters.py +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/path_policy.py +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/project.py +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/py.typed +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/registry.py +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/remote/AGENTS.md +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/remote/__init__.py +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/remote/config.py +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/remote/git_archive.py +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/remote/github.py +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/remote/storage.py +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/remote/sync.py +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/run_history.py +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/show/__init__.py +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/show/common.py +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/show/data.py +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/show/metrics.py +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/show/params.py +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/show/plots.py +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/skip.py +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/stage_def.py +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/status.py +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/storage/AGENTS.md +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/storage/CLAUDE.md +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/storage/__init__.py +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/storage/artifact_lock.py +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/storage/lock.py +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/storage/presentation.py +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/storage/restore.py +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/storage/state.py +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/storage/store.py +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/storage/track.py +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/types.py +0 -0
- {pivot-0.3.2 → pivot-0.3.3}/src/pivot/yaml_config.py +0 -0
|
@@ -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
|
-
|
|
234
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|