pivot 0.3.3__tar.gz → 0.3.4__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.3 → pivot-0.3.4}/PKG-INFO +1 -1
  2. {pivot-0.3.3 → pivot-0.3.4}/pyproject.toml +1 -1
  3. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/cli/checkout.py +9 -5
  4. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/cli/track.py +5 -2
  5. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/executor/core.py +1 -1
  6. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/executor/worker.py +3 -9
  7. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/explain.py +4 -8
  8. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/remote/sync.py +2 -4
  9. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/run_history.py +7 -1
  10. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/status.py +1 -1
  11. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/storage/cache.py +205 -41
  12. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/storage/lock.py +38 -15
  13. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/storage/restore.py +4 -17
  14. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/storage/store.py +5 -9
  15. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/storage/track.py +23 -4
  16. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/types.py +13 -3
  17. {pivot-0.3.3 → pivot-0.3.4}/LICENSE +0 -0
  18. {pivot-0.3.3 → pivot-0.3.4}/README.md +0 -0
  19. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/__init__.py +0 -0
  20. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/ast_utils.py +0 -0
  21. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/cli/AGENTS.md +0 -0
  22. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/cli/CLAUDE.md +0 -0
  23. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/cli/__init__.py +0 -0
  24. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/cli/_run_common.py +0 -0
  25. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/cli/check_ignore.py +0 -0
  26. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/cli/commit.py +0 -0
  27. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/cli/completion.py +0 -0
  28. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/cli/config.py +0 -0
  29. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/cli/console.py +0 -0
  30. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/cli/dag.py +0 -0
  31. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/cli/data.py +0 -0
  32. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/cli/decorators.py +0 -0
  33. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/cli/doctor.py +0 -0
  34. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/cli/errors.py +0 -0
  35. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/cli/fingerprint.py +0 -0
  36. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/cli/helpers.py +0 -0
  37. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/cli/history.py +0 -0
  38. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/cli/import_cmd.py +0 -0
  39. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/cli/init.py +0 -0
  40. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/cli/list.py +0 -0
  41. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/cli/metrics.py +0 -0
  42. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/cli/params.py +0 -0
  43. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/cli/plots.py +0 -0
  44. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/cli/remote.py +0 -0
  45. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/cli/repro.py +0 -0
  46. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/cli/run.py +0 -0
  47. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/cli/status.py +0 -0
  48. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/cli/targets.py +0 -0
  49. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/cli/update.py +0 -0
  50. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/cli/verify.py +0 -0
  51. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/compose.py +0 -0
  52. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/config/__init__.py +0 -0
  53. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/config/io.py +0 -0
  54. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/config/models.py +0 -0
  55. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/dag/__init__.py +0 -0
  56. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/dag/render.py +0 -0
  57. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/decorators.py +0 -0
  58. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/discovery.py +0 -0
  59. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/engine/__init__.py +0 -0
  60. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/engine/agent_rpc.py +0 -0
  61. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/engine/engine.py +0 -0
  62. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/engine/graph.py +0 -0
  63. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/engine/scheduler.py +0 -0
  64. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/engine/sinks.py +0 -0
  65. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/engine/sources.py +0 -0
  66. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/engine/types.py +0 -0
  67. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/engine/watch.py +0 -0
  68. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/engine/worker_pool.py +0 -0
  69. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/exceptions.py +0 -0
  70. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/executor/AGENTS.md +0 -0
  71. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/executor/CLAUDE.md +0 -0
  72. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/executor/__init__.py +0 -0
  73. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/executor/commit.py +0 -0
  74. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/fingerprint.py +0 -0
  75. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/git.py +0 -0
  76. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/ignore.py +0 -0
  77. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/import_artifact.py +0 -0
  78. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/loaders.py +0 -0
  79. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/metrics.py +0 -0
  80. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/names.py +0 -0
  81. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/outputs.py +0 -0
  82. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/parameters.py +0 -0
  83. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/path_policy.py +0 -0
  84. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/project.py +0 -0
  85. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/py.typed +0 -0
  86. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/registry.py +0 -0
  87. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/remote/AGENTS.md +0 -0
  88. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/remote/__init__.py +0 -0
  89. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/remote/config.py +0 -0
  90. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/remote/git_archive.py +0 -0
  91. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/remote/github.py +0 -0
  92. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/remote/storage.py +0 -0
  93. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/show/__init__.py +0 -0
  94. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/show/common.py +0 -0
  95. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/show/data.py +0 -0
  96. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/show/metrics.py +0 -0
  97. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/show/params.py +0 -0
  98. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/show/plots.py +0 -0
  99. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/skip.py +0 -0
  100. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/stage_def.py +0 -0
  101. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/storage/AGENTS.md +0 -0
  102. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/storage/CLAUDE.md +0 -0
  103. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/storage/__init__.py +0 -0
  104. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/storage/artifact_lock.py +0 -0
  105. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/storage/presentation.py +0 -0
  106. {pivot-0.3.3 → pivot-0.3.4}/src/pivot/storage/state.py +0 -0
  107. {pivot-0.3.3 → pivot-0.3.4}/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.3
3
+ Version: 0.3.4
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.3"
3
+ version = "0.3.4"
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"
@@ -74,11 +74,15 @@ def _on_disk_matches_expected(
74
74
  if is_dir_hash(expected_hash):
75
75
  if not path.is_dir():
76
76
  return False
77
- actual_hash, _ = cache.hash_directory(path, cache_dir=cache_dir)
78
- else:
79
- if not path.is_file():
80
- return False
81
- actual_hash, _ = cache.hash_file(path)
77
+ actual = cache.hash_directory(path, cache_dir=cache_dir)
78
+ # A hash pivot 0.3.3 recorded lists no empty directories, and repro still honors it.
79
+ # When only empty directories differ, checkout would change no file, so it matches.
80
+ return actual["hash"] == expected_hash["hash"] or (
81
+ "empty_dirs" not in expected_hash and actual["manifest"] == expected_hash["manifest"]
82
+ )
83
+ if not path.is_file():
84
+ return False
85
+ actual_hash, _ = cache.hash_file(path)
82
86
  return actual_hash == expected_hash["hash"]
83
87
 
84
88
 
@@ -154,9 +154,10 @@ def _track_single_path(
154
154
 
155
155
  # Hash and cache
156
156
  if path.is_dir():
157
- tree_hash, manifest = cache.hash_directory(
157
+ dir_hash = cache.hash_directory(
158
158
  path, cache_dir=cache_dir, warn_left_out_symlinks=warn_left_out_symlinks
159
159
  )
160
+ manifest = dir_hash["manifest"]
160
161
  total_size = sum(e["size"] for e in manifest)
161
162
  num_files = len(manifest)
162
163
 
@@ -171,11 +172,13 @@ def _track_single_path(
171
172
 
172
173
  pvt_data: track_mod.PvtData = {
173
174
  "path": path.name,
174
- "hash": tree_hash,
175
+ "hash": dir_hash["hash"],
175
176
  "size": total_size,
176
177
  "num_files": num_files,
177
178
  "manifest": manifest,
178
179
  }
180
+ if "empty_dirs" in dir_hash:
181
+ pvt_data["empty_dirs"] = dir_hash["empty_dirs"]
179
182
  else:
180
183
  file_hash, _ = cache.hash_file(path)
181
184
  file_size = path.stat().st_size
@@ -409,7 +409,7 @@ def verify_tracked_files(project_root: pathlib.Path, checkout_missing: bool = Fa
409
409
  if path.is_file():
410
410
  current_hash, _ = cache.hash_file(path, state_db)
411
411
  elif path.is_dir():
412
- current_hash, _ = cache.hash_directory(path, state_db, cache_dir=cache_dir)
412
+ current_hash = cache.hash_directory(path, state_db, cache_dir=cache_dir)["hash"]
413
413
  else:
414
414
  # Path doesn't exist
415
415
  raise FileNotFoundError(data_path)
@@ -39,7 +39,6 @@ from pivot.storage import artifact_lock, cache, lock, state
39
39
  from pivot.storage import store as store_mod
40
40
  from pivot.types import (
41
41
  DeferredWrites,
42
- DirHash,
43
42
  FileHash,
44
43
  HashInfo,
45
44
  LockData,
@@ -631,8 +630,7 @@ def hash_output(
631
630
  ``cache_dir`` is the cache object root whose symlinks count as directory content.
632
631
  """
633
632
  if path.is_dir():
634
- tree_hash, manifest = cache.hash_directory(path, state_db, cache_dir=cache_dir)
635
- return DirHash(hash=tree_hash, manifest=manifest)
633
+ return cache.hash_directory(path, state_db, cache_dir=cache_dir)
636
634
  file_hash, _ = cache.hash_file(path, state_db)
637
635
  return FileHash(hash=file_hash)
638
636
 
@@ -1063,12 +1061,9 @@ def _hash_output_paths(
1063
1061
  if not path.exists():
1064
1062
  raise exceptions.OutputMissingError(f"Stage did not produce output: {path}")
1065
1063
  if path.is_dir():
1066
- tree_hash, manifest = cache.hash_directory(
1064
+ output_hashes[types.identity_key(ref.identity)] = cache.hash_directory(
1067
1065
  path, state_db, cache_dir=cache_dir, warn_left_out_symlinks=True
1068
1066
  )
1069
- output_hashes[types.identity_key(ref.identity)] = DirHash(
1070
- hash=tree_hash, manifest=manifest
1071
- )
1072
1067
  else:
1073
1068
  file_hash, _ = cache.hash_file(path, state_db)
1074
1069
  output_hashes[types.identity_key(ref.identity)] = FileHash(hash=file_hash)
@@ -1290,8 +1285,7 @@ def hash_dependencies(
1290
1285
  path = pathlib.Path(normalized_path)
1291
1286
  try:
1292
1287
  if path.is_dir():
1293
- tree_hash, manifest = cache.hash_directory(path, state_db)
1294
- hashes[dep] = DirHash(hash=tree_hash, manifest=manifest)
1288
+ hashes[dep] = cache.hash_directory(path, state_db)
1295
1289
  else:
1296
1290
  file_hash, fs_stat = cache.hash_file(path, state_db)
1297
1291
  hashes[dep] = FileHash(hash=file_hash)
@@ -14,7 +14,7 @@ import pydantic
14
14
 
15
15
  from pivot import parameters, project, registry, skip, types
16
16
  from pivot.executor import worker
17
- from pivot.storage import lock, state
17
+ from pivot.storage import lock, state, track
18
18
  from pivot.storage import store as store_mod
19
19
  from pivot.types import (
20
20
  ArtifactIdentity,
@@ -30,8 +30,6 @@ if TYPE_CHECKING:
30
30
 
31
31
  import pygtrie
32
32
 
33
- from pivot.storage.track import PvtData
34
-
35
33
  OUTPUTS_MISSING_REASON = "Outputs missing from cache"
36
34
  """Reason for a stage whose lock is current but whose cacheable outputs are absent locally.
37
35
 
@@ -105,7 +103,7 @@ def _find_tracked_ancestor(dep: Path, tracked_trie: pygtrie.Trie[str]) -> Path |
105
103
 
106
104
  def _find_tracked_hash(
107
105
  dep: Path,
108
- tracked_files: dict[str, PvtData],
106
+ tracked_files: dict[str, track.PvtData],
109
107
  tracked_trie: pygtrie.Trie[str],
110
108
  ) -> HashInfo | None:
111
109
  """Find hash for dep from tracked files data.
@@ -121,9 +119,7 @@ def _find_tracked_hash(
121
119
 
122
120
  # Exact match - use top-level hash
123
121
  if dep == tracked_path:
124
- if "manifest" in pvt_data:
125
- return {"hash": pvt_data["hash"], "manifest": pvt_data["manifest"]}
126
- return {"hash": pvt_data["hash"]}
122
+ return track.pvt_to_hash_info(pvt_data)
127
123
 
128
124
  # Nested path - find in manifest
129
125
  if "manifest" not in pvt_data:
@@ -155,7 +151,7 @@ def get_stage_explanation(
155
151
  lock_data: LockData | None,
156
152
  force: bool = False,
157
153
  allow_missing: bool = False,
158
- tracked_files: dict[str, PvtData] | None = None,
154
+ tracked_files: dict[str, track.PvtData] | None = None,
159
155
  tracked_trie: pygtrie.Trie[str] | None = None,
160
156
  producer_hashes: dict[ArtifactIdentity, HashInfo] | None = None,
161
157
  ) -> StageExplanation:
@@ -11,7 +11,7 @@ from pivot.remote import config as remote_config
11
11
  from pivot.remote import storage as remote_mod
12
12
  from pivot.storage import cache, lock, track
13
13
  from pivot.storage import store as store_mod
14
- from pivot.types import DirHash, FileHash, HashInfo, RemoteStatus, TransferSummary, is_dir_hash
14
+ from pivot.types import HashInfo, RemoteStatus, TransferSummary, is_dir_hash
15
15
 
16
16
  if TYPE_CHECKING:
17
17
  from collections.abc import Callable
@@ -175,9 +175,7 @@ def _get_file_hash_from_pvt(rel_path: str, proj_root: pathlib.Path) -> HashInfo
175
175
  if track_data is None:
176
176
  return None
177
177
 
178
- if "manifest" in track_data:
179
- return DirHash(hash=track_data["hash"], manifest=track_data["manifest"])
180
- return FileHash(hash=track_data["hash"])
178
+ return track.pvt_to_hash_info(track_data)
181
179
 
182
180
 
183
181
  def _get_tracked_hashes(proj_root: pathlib.Path) -> set[str]:
@@ -64,6 +64,7 @@ class OutputHashEntry(TypedDict):
64
64
  path: str
65
65
  hash: str
66
66
  manifest: NotRequired[list[DirManifestEntry]]
67
+ empty_dirs: NotRequired[list[str]]
67
68
 
68
69
 
69
70
  def output_hash_to_entry(path: str, oh: HashInfo) -> OutputHashEntry:
@@ -71,6 +72,8 @@ def output_hash_to_entry(path: str, oh: HashInfo) -> OutputHashEntry:
71
72
  entry = OutputHashEntry(path=path, hash=oh["hash"])
72
73
  if is_dir_hash(oh):
73
74
  entry["manifest"] = oh["manifest"]
75
+ if "empty_dirs" in oh:
76
+ entry["empty_dirs"] = oh["empty_dirs"]
74
77
  return entry
75
78
 
76
79
 
@@ -78,7 +81,10 @@ def entry_to_output_hash(entry: OutputHashEntry) -> FileHash | DirHash:
78
81
  """Convert serialized OutputHashEntry back to internal HashInfo."""
79
82
  # Null check guards against corrupted JSON where manifest could be null
80
83
  if "manifest" in entry and entry["manifest"] is not None: # pyright: ignore[reportUnnecessaryComparison]
81
- return DirHash(hash=entry["hash"], manifest=entry["manifest"])
84
+ dir_hash = DirHash(hash=entry["hash"], manifest=entry["manifest"])
85
+ if "empty_dirs" in entry and entry["empty_dirs"]:
86
+ dir_hash["empty_dirs"] = entry["empty_dirs"]
87
+ return dir_hash
82
88
  return FileHash(hash=entry["hash"])
83
89
 
84
90
 
@@ -356,7 +356,7 @@ def get_tracked_files_status(
356
356
 
357
357
  try:
358
358
  if path.is_dir():
359
- current_hash, _ = cache.hash_directory(path, state_db, cache_dir=cache_dir)
359
+ current_hash = cache.hash_directory(path, state_db, cache_dir=cache_dir)["hash"]
360
360
  else:
361
361
  current_hash, _ = cache.hash_file(path, state_db)
362
362
  except FileNotFoundError:
@@ -9,6 +9,7 @@ import logging
9
9
  import mmap
10
10
  import os
11
11
  import pathlib
12
+ import posixpath
12
13
  import secrets
13
14
  import shutil
14
15
  import stat
@@ -25,7 +26,7 @@ from pivot.types import DirHash, DirManifestEntry, FileHash, HashInfo, is_dir_ha
25
26
  logger = logging.getLogger(__name__)
26
27
 
27
28
  if TYPE_CHECKING:
28
- from collections.abc import Callable, Generator
29
+ from collections.abc import Callable, Generator, Iterable
29
30
 
30
31
  from pivot.storage import state as state_mod
31
32
 
@@ -186,7 +187,10 @@ def _cache_object_key(path: pathlib.Path, cache_dir: pathlib.Path | None) -> str
186
187
 
187
188
 
188
189
  def _scandir_recursive(
189
- path: pathlib.Path, cache_dir: pathlib.Path | None, left_out: list[pathlib.Path]
190
+ path: pathlib.Path,
191
+ cache_dir: pathlib.Path | None,
192
+ left_out: list[pathlib.Path],
193
+ dirs: list[pathlib.Path],
190
194
  ) -> Generator[os.DirEntry[str]]:
191
195
  """Yield every file under `path` recursively, using os.scandir() for efficiency.
192
196
 
@@ -202,6 +206,7 @@ def _scandir_recursive(
202
206
  path: Directory to scan
203
207
  cache_dir: Cache object root whose links count as content, or None for no cache
204
208
  left_out: Collects the symlinks that are not part of the artifact
209
+ dirs: Collects every directory under ``path`` (symlinked directories are not followed)
205
210
  """
206
211
  with os.scandir(path) as entries:
207
212
  for entry in entries:
@@ -213,7 +218,38 @@ def _scandir_recursive(
213
218
  elif entry.is_file():
214
219
  yield entry
215
220
  elif entry.is_dir():
216
- yield from _scandir_recursive(pathlib.Path(entry.path), cache_dir, left_out)
221
+ dirs.append(pathlib.Path(entry.path))
222
+ yield from _scandir_recursive(pathlib.Path(entry.path), cache_dir, left_out, dirs)
223
+
224
+
225
+ def _parent_dirs(relpaths: Iterable[str]) -> set[str]:
226
+ """Every directory above each relpath, short of the root, as relpaths."""
227
+ return {
228
+ str(parent) for relpath in relpaths for parent in pathlib.PurePath(relpath).parents[:-1]
229
+ }
230
+
231
+
232
+ def _recorded_empty_dirs(output_hash: DirHash) -> list[str]:
233
+ return output_hash["empty_dirs"] if "empty_dirs" in output_hash else []
234
+
235
+
236
+ def _restored_dirs(output_hash: DirHash) -> set[str]:
237
+ """Every directory a restore of ``output_hash`` creates under its root, as relpaths."""
238
+ empty_dirs = _recorded_empty_dirs(output_hash)
239
+ file_parents = _parent_dirs(entry["relpath"] for entry in output_hash["manifest"])
240
+ return file_parents | _parent_dirs(empty_dirs) | set(empty_dirs)
241
+
242
+
243
+ def _empty_dir_path(root: pathlib.Path, relpath: str) -> pathlib.Path:
244
+ """Return where the recorded empty directory ``relpath`` belongs under ``root``.
245
+
246
+ ``relpath`` may come straight from a lock file, so one leading outside ``root`` is refused,
247
+ as the file-by-file restore refuses a manifest entry's.
248
+ """
249
+ target = root / relpath
250
+ if not target.resolve().is_relative_to(root.resolve()):
251
+ raise exceptions.SecurityValidationError(f"Manifest contains path traversal: {relpath!r}")
252
+ return target
217
253
 
218
254
 
219
255
  def _warn_left_out_symlinks(path: pathlib.Path, left_out: list[pathlib.Path]) -> None:
@@ -239,8 +275,12 @@ def hash_directory(
239
275
  *,
240
276
  cache_dir: pathlib.Path | None = None,
241
277
  warn_left_out_symlinks: bool = False,
242
- ) -> tuple[str, list[DirManifestEntry]]:
243
- """Compute tree hash of directory, returning hash and manifest.
278
+ ) -> DirHash:
279
+ """Compute the tree hash of a directory, returning it with the manifest.
280
+
281
+ The hash covers the manifest and the directory's empty directories (see ``DirHash``). With
282
+ no empty directories it hashes the manifest alone, so recording them changed no hash of a
283
+ directory that holds none.
244
284
 
245
285
  Symlink handling:
246
286
  - A symlink to an object in ``cache_dir`` is hashed by its target's content under its own
@@ -263,9 +303,10 @@ def hash_directory(
263
303
  _t = metrics.start()
264
304
  manifest = list[DirManifestEntry]()
265
305
  left_out = list[pathlib.Path]()
306
+ dirs = list[pathlib.Path]()
266
307
  resolved_base = path.resolve()
267
308
 
268
- for entry in sorted(_scandir_recursive(path, cache_dir, left_out), key=lambda e: e.path):
309
+ for entry in sorted(_scandir_recursive(path, cache_dir, left_out, dirs), key=lambda e: e.path):
269
310
  file_path = pathlib.Path(entry.path)
270
311
  # Verify a regular file is still within the directory (paranoid check). A cache-object
271
312
  # link resolves into the cache by design.
@@ -291,11 +332,20 @@ def hash_directory(
291
332
  if left_out and warn_left_out_symlinks:
292
333
  _warn_left_out_symlinks(path, left_out)
293
334
 
294
- manifest_json = json.dumps(manifest, sort_keys=True, separators=(",", ":"))
295
- tree_hash = xxhash.xxh64(manifest_json.encode()).hexdigest()
335
+ # A deepest empty directory holds no stored file and no other directory.
336
+ found_dirs = {str(directory.relative_to(path)) for directory in dirs}
337
+ file_dirs = {posixpath.dirname(entry["relpath"]) for entry in manifest}
338
+ has_child = {posixpath.dirname(directory) for directory in found_dirs}
339
+ empty_dirs = sorted(found_dirs - file_dirs - has_child)
340
+
341
+ hashed: object = {"empty_dirs": empty_dirs, "manifest": manifest} if empty_dirs else manifest
342
+ hashed_json = json.dumps(hashed, sort_keys=True, separators=(",", ":"))
343
+ output_hash = DirHash(hash=xxhash.xxh64(hashed_json.encode()).hexdigest(), manifest=manifest)
344
+ if empty_dirs:
345
+ output_hash["empty_dirs"] = empty_dirs
296
346
 
297
347
  metrics.end("cache.hash_directory", _t)
298
- return tree_hash, manifest
348
+ return output_hash
299
349
 
300
350
 
301
351
  _VALID_HASH_CHARS = frozenset("0123456789abcdef")
@@ -586,6 +636,85 @@ def _repair_tree_dir_isexec(cache_dir_path: pathlib.Path, manifest: list[DirMani
586
636
  os.chmod(f, 0o555)
587
637
 
588
638
 
639
+ _TREE_LAYOUT_MARKER_SUFFIX = ".layout-checked"
640
+
641
+
642
+ def _tree_layout_marker(cache_dir_path: pathlib.Path) -> pathlib.Path:
643
+ """Return the file beside a whole-directory SYMLINK cache tree that records its layout
644
+ holds exactly what its hash records. The name is never a cache key, so cache scans skip it.
645
+ """
646
+ return cache_dir_path.with_name(cache_dir_path.name + _TREE_LAYOUT_MARKER_SUFFIX)
647
+
648
+
649
+ def _ensure_tree_dir_layout(cache_dir_path: pathlib.Path, output_hash: DirHash) -> None:
650
+ """Repair a whole-directory SYMLINK cache tree's layout once, not on every restore.
651
+
652
+ Only this pivot records ``empty_dirs``, so a tree whose hash records them was built by it
653
+ and needs no check. Any other tree is checked once and then marked.
654
+ """
655
+ if "empty_dirs" in output_hash:
656
+ return
657
+ marker = _tree_layout_marker(cache_dir_path)
658
+ if marker.exists():
659
+ return
660
+ _repair_tree_dir_layout(cache_dir_path, output_hash)
661
+ # The marker only saves a walk; a cache the restoring user cannot write still restores.
662
+ with contextlib.suppress(OSError):
663
+ marker.touch()
664
+
665
+
666
+ def _repair_tree_dir_layout(cache_dir_path: pathlib.Path, output_hash: DirHash) -> None:
667
+ """Give a whole-directory SYMLINK cache tree exactly the files and directories
668
+ ``output_hash`` records.
669
+
670
+ A tree an older pivot built can hold entries its hash does not record: directories its hash
671
+ left out because they were empty, and copies of the targets of symlinks its hash left out.
672
+ The tree's name never changes, so neither _store_directory's ``not cache_dir_path.exists()``
673
+ branch nor the symlink_fast_path restore would otherwise revisit it, and an output restored
674
+ through it would not hash to the hash it was restored for. Changing the tree in place is
675
+ safe for the reason ``_repair_tree_dir_isexec`` gives.
676
+ """
677
+ found_files, found = _scan_entries(cache_dir_path)
678
+ found_dirs = set(found)
679
+ unlisted = set(found_files) - {entry["relpath"] for entry in output_hash["manifest"]}
680
+ extra_dirs = found_dirs - _restored_dirs(output_hash)
681
+ missing = [
682
+ _empty_dir_path(cache_dir_path, relpath)
683
+ for relpath in _recorded_empty_dirs(output_hash)
684
+ if relpath not in found_dirs
685
+ ]
686
+ if not unlisted and not extra_dirs and not missing:
687
+ return
688
+ modes = dict[pathlib.Path, int]()
689
+
690
+ def _make_writable(directory: pathlib.Path) -> None:
691
+ if directory not in modes:
692
+ modes[directory] = directory.stat().st_mode
693
+ os.chmod(directory, modes[directory] | stat.S_IWUSR)
694
+
695
+ try:
696
+ for relpath in unlisted:
697
+ target = cache_dir_path / relpath
698
+ _make_writable(target.parent)
699
+ target.unlink(missing_ok=True)
700
+ # Reverse order reaches a directory's children before the directory itself.
701
+ for relpath in sorted(extra_dirs, reverse=True):
702
+ target = cache_dir_path / relpath
703
+ _make_writable(target.parent)
704
+ modes.pop(target, None)
705
+ with contextlib.suppress(FileNotFoundError): # a concurrent repair got there first
706
+ target.rmdir()
707
+ for target in missing:
708
+ existing = target.parent
709
+ while not existing.exists():
710
+ existing = existing.parent
711
+ _make_writable(existing)
712
+ target.mkdir(parents=True, exist_ok=True)
713
+ finally:
714
+ for directory, mode in modes.items():
715
+ os.chmod(directory, mode)
716
+
717
+
589
718
  def _checkout_with_fallback(
590
719
  path: pathlib.Path,
591
720
  cache_path: pathlib.Path,
@@ -763,18 +892,17 @@ def _save_directory_to_cache(
763
892
  state_dir: pathlib.Path | None,
764
893
  ) -> DirHash:
765
894
  """Save directory to cache."""
766
- # Idempotency check for SYMLINK mode
767
- if checkout_modes and checkout_modes[0] == CheckoutMode.SYMLINK and path.is_symlink():
768
- existing_hash = _get_symlink_cache_hash(path, cache_dir)
769
- if existing_hash is not None:
770
- cache_dir_path = get_cache_path(cache_dir, existing_hash)
771
- if cache_dir_path.exists():
772
- # Already correctly linked - compute manifest from actual content
773
- _, manifest = hash_directory(path, state_db, cache_dir=cache_dir)
774
- return DirHash(hash=existing_hash, manifest=manifest)
775
-
776
- tree_hash, manifest = hash_directory(path, state_db, cache_dir=cache_dir)
777
- output_hash = DirHash(hash=tree_hash, manifest=manifest)
895
+ output_hash = hash_directory(path, state_db, cache_dir=cache_dir)
896
+ # Idempotency check for SYMLINK mode: already linked to the tree its content hashes to
897
+ if (
898
+ checkout_modes
899
+ and checkout_modes[0] == CheckoutMode.SYMLINK
900
+ and path.is_symlink()
901
+ and _get_symlink_cache_hash(path, cache_dir) == output_hash["hash"]
902
+ and get_cache_path(cache_dir, output_hash["hash"]).exists()
903
+ ):
904
+ return output_hash
905
+
778
906
  _store_directory(
779
907
  path,
780
908
  output_hash,
@@ -808,7 +936,17 @@ def _store_directory(
808
936
  cache_dir_path = get_cache_path(cache_dir, output_hash["hash"])
809
937
  if not cache_dir_path.exists():
810
938
  cache_dir_path.parent.mkdir(parents=True, exist_ok=True)
811
- shutil.copytree(path, cache_dir_path)
939
+
940
+ def _user_symlinks(directory: str, names: list[str]) -> list[str]:
941
+ # Not stored (see _scandir_recursive); copytree would copy each target.
942
+ return [
943
+ name
944
+ for name in names
945
+ if (link := pathlib.Path(directory, name)).is_symlink()
946
+ and _cache_object_key(link, cache_dir) is None
947
+ ]
948
+
949
+ shutil.copytree(path, cache_dir_path, ignore=_user_symlinks)
812
950
  isexec_by_relpath = {entry["relpath"]: entry["isexec"] for entry in manifest}
813
951
  for f in cache_dir_path.rglob("*"):
814
952
  # Skip symlinks to avoid changing permissions on target files outside cache
@@ -817,11 +955,14 @@ def _store_directory(
817
955
  executable = isexec_by_relpath.get(relpath, False)
818
956
  os.chmod(f, 0o555 if executable else 0o444)
819
957
  os.chmod(cache_dir_path, 0o555)
958
+ with contextlib.suppress(OSError):
959
+ _tree_layout_marker(cache_dir_path).touch()
820
960
 
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.
961
+ # A tree an older pivot built keeps its 0o444-everywhere files, and entries its hash
962
+ # does not record, forever otherwise: its name never changes, so this branch never
963
+ # revisits an existing tree directory above.
824
964
  _repair_tree_dir_isexec(cache_dir_path, manifest)
965
+ _ensure_tree_dir_layout(cache_dir_path, output_hash)
825
966
 
826
967
  _clear_path(path)
827
968
  path.symlink_to(cache_dir_path.resolve())
@@ -994,10 +1135,11 @@ def _restore_directory_from_cache(
994
1135
  restore_prefix = f"{_RESTORE_TEMP_PREFIX}{target_token}."
995
1136
 
996
1137
  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.
1138
+ # Same repairs as _store_directory: a tree an older pivot built keeps its
1139
+ # 0o444-everywhere files, and entries its hash does not record, forever otherwise,
1140
+ # since this path never rebuilds an existing tree directory.
1000
1141
  _repair_tree_dir_isexec(cache_dir_path, output_hash["manifest"])
1142
+ _ensure_tree_dir_layout(cache_dir_path, output_hash)
1001
1143
  temp_symlink = path.parent / f"{restore_prefix}symlink_{secrets.token_hex(4)}"
1002
1144
  try:
1003
1145
  temp_symlink.symlink_to(cache_dir_path.resolve())
@@ -1039,6 +1181,9 @@ def _restore_directory_from_cache(
1039
1181
  except OSError as e:
1040
1182
  logger.debug(f"mtime refresh failed (may be NFS with root_squash): {e}")
1041
1183
 
1184
+ for relpath in _recorded_empty_dirs(output_hash):
1185
+ _empty_dir_path(temp_path, relpath).mkdir(parents=True, exist_ok=True)
1186
+
1042
1187
  try:
1043
1188
  os.utime(temp_path, None)
1044
1189
  except OSError as e:
@@ -1090,7 +1235,7 @@ def remove_output(path: pathlib.Path) -> None:
1090
1235
  class MissingRestore(TypedDict):
1091
1236
  """Outcome of restore_missing_in_directory."""
1092
1237
 
1093
- restored: int # missing files restored in place
1238
+ restored: int # missing files and empty directories restored in place
1094
1239
  unavailable: int # missing files whose blob is not in the cache
1095
1240
  local_changes: list[str] # changed or extra files whose content the cache lacks
1096
1241
  replace: bool # directory holds only cached content but not the target: replace it whole
@@ -1104,12 +1249,13 @@ def _is_cached(cache_dir: pathlib.Path, content_hash: str | None) -> bool:
1104
1249
  )
1105
1250
 
1106
1251
 
1107
- def _scan_entries(path: pathlib.Path) -> list[str]:
1108
- """Return every file and symlink under ``path`` as relative paths.
1252
+ def _scan_entries(path: pathlib.Path) -> tuple[list[str], list[str]]:
1253
+ """Return every file and symlink, and every directory, under ``path`` as relative paths.
1109
1254
 
1110
- Symlinked directories are not followed.
1255
+ Symlinked directories count as symlinks and are not followed.
1111
1256
  """
1112
1257
  files = list[str]()
1258
+ dirs = list[str]()
1113
1259
 
1114
1260
  def _walk(directory: pathlib.Path) -> None:
1115
1261
  with os.scandir(directory) as entries:
@@ -1119,10 +1265,11 @@ def _scan_entries(path: pathlib.Path) -> list[str]:
1119
1265
  if entry.is_symlink() or entry.is_file():
1120
1266
  files.append(relpath)
1121
1267
  elif entry.is_dir():
1268
+ dirs.append(relpath)
1122
1269
  _walk(entry_path)
1123
1270
 
1124
1271
  _walk(path)
1125
- return files
1272
+ return files, dirs
1126
1273
 
1127
1274
 
1128
1275
  def _is_same_file(file_stat: os.stat_result, cache_path: pathlib.Path) -> bool:
@@ -1142,12 +1289,13 @@ def restore_missing_in_directory(
1142
1289
  checkout_modes: list[CheckoutMode],
1143
1290
  state_db: state_mod.StateDB | None = None,
1144
1291
  ) -> MissingRestore:
1145
- """Restore missing target files without overwriting local changes.
1292
+ """Restore missing target files and empty directories without overwriting local changes.
1146
1293
 
1147
- When every existing file matches the target, missing files are restored in place. When a
1148
- file differs from the target or an extra file exists, only a whole-directory swap can
1149
- restore the target. The swap is safe only when every file it removes is in the cache;
1150
- otherwise the local changes are returned without writing anything.
1294
+ When every existing file matches the target, missing files and empty directories are
1295
+ restored in place. When a file differs from the target or an extra file or directory
1296
+ exists, only a whole-directory swap can restore the target. The swap is safe only when
1297
+ every file it removes is in the cache (an extra empty directory removes none); otherwise
1298
+ the local changes are returned without writing anything.
1151
1299
 
1152
1300
  A retained file is read only when nothing else proves its content: a symlink to the
1153
1301
  target's cache path, a hardlink to the target's cache blob, or a matching ``state_db``
@@ -1198,7 +1346,15 @@ def restore_missing_in_directory(
1198
1346
  )
1199
1347
  missing.append((entry, file_path, file_cache_path))
1200
1348
 
1201
- files = _scan_entries(path)
1349
+ missing_dirs = [
1350
+ dir_path
1351
+ for dir_path in (
1352
+ _empty_dir_path(path, relpath) for relpath in _recorded_empty_dirs(output_hash)
1353
+ )
1354
+ if not dir_path.exists() and not dir_path.is_symlink()
1355
+ ]
1356
+
1357
+ files, dirs = _scan_entries(path)
1202
1358
  for relpath in files:
1203
1359
  if relpath in expected_paths:
1204
1360
  continue
@@ -1207,8 +1363,12 @@ def restore_missing_in_directory(
1207
1363
  changed[relpath] = _get_symlink_cache_hash(extra_path, cache_dir)
1208
1364
  else:
1209
1365
  changed[relpath], _ = hash_file(extra_path, state_db)
1366
+ # A hash pivot 0.3.3 recorded lists no empty directories, so on disk none of them is extra.
1367
+ extra_dirs = (
1368
+ set(dirs) - _restored_dirs(output_hash) if "empty_dirs" in output_hash else set[str]()
1369
+ )
1210
1370
 
1211
- if changed:
1371
+ if changed or extra_dirs:
1212
1372
  # Replacing the directory deletes every file it holds; refuse if any is not cached.
1213
1373
  local_changes = sorted(
1214
1374
  rel for rel, content_hash in changed.items() if not _is_cached(cache_dir, content_hash)
@@ -1228,8 +1388,12 @@ def restore_missing_in_directory(
1228
1388
  _checkout_with_fallback(
1229
1389
  file_path, file_cache_path, checkout_modes, executable=entry["isexec"]
1230
1390
  )
1391
+ for dir_path in missing_dirs:
1392
+ dir_path.mkdir(parents=True, exist_ok=True)
1231
1393
 
1232
- return MissingRestore(restored=len(missing), unavailable=0, local_changes=[], replace=False)
1394
+ return MissingRestore(
1395
+ restored=len(missing) + len(missing_dirs), unavailable=0, local_changes=[], replace=False
1396
+ )
1233
1397
 
1234
1398
 
1235
1399
  def protect(path: pathlib.Path) -> None: