pivot 0.3.2__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.2 → pivot-0.3.4}/PKG-INFO +1 -1
  2. {pivot-0.3.2 → pivot-0.3.4}/pyproject.toml +1 -1
  3. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/cli/checkout.py +18 -9
  4. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/cli/track.py +11 -7
  5. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/exceptions.py +21 -0
  6. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/executor/core.py +1 -1
  7. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/executor/worker.py +3 -9
  8. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/explain.py +4 -8
  9. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/remote/sync.py +2 -4
  10. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/run_history.py +7 -1
  11. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/status.py +1 -1
  12. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/storage/cache.py +310 -46
  13. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/storage/lock.py +38 -15
  14. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/storage/restore.py +4 -17
  15. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/storage/store.py +5 -9
  16. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/storage/track.py +23 -4
  17. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/types.py +13 -3
  18. {pivot-0.3.2 → pivot-0.3.4}/LICENSE +0 -0
  19. {pivot-0.3.2 → pivot-0.3.4}/README.md +0 -0
  20. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/__init__.py +0 -0
  21. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/ast_utils.py +0 -0
  22. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/cli/AGENTS.md +0 -0
  23. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/cli/CLAUDE.md +0 -0
  24. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/cli/__init__.py +0 -0
  25. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/cli/_run_common.py +0 -0
  26. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/cli/check_ignore.py +0 -0
  27. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/cli/commit.py +0 -0
  28. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/cli/completion.py +0 -0
  29. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/cli/config.py +0 -0
  30. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/cli/console.py +0 -0
  31. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/cli/dag.py +0 -0
  32. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/cli/data.py +0 -0
  33. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/cli/decorators.py +0 -0
  34. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/cli/doctor.py +0 -0
  35. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/cli/errors.py +0 -0
  36. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/cli/fingerprint.py +0 -0
  37. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/cli/helpers.py +0 -0
  38. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/cli/history.py +0 -0
  39. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/cli/import_cmd.py +0 -0
  40. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/cli/init.py +0 -0
  41. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/cli/list.py +0 -0
  42. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/cli/metrics.py +0 -0
  43. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/cli/params.py +0 -0
  44. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/cli/plots.py +0 -0
  45. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/cli/remote.py +0 -0
  46. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/cli/repro.py +0 -0
  47. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/cli/run.py +0 -0
  48. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/cli/status.py +0 -0
  49. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/cli/targets.py +0 -0
  50. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/cli/update.py +0 -0
  51. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/cli/verify.py +0 -0
  52. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/compose.py +0 -0
  53. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/config/__init__.py +0 -0
  54. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/config/io.py +0 -0
  55. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/config/models.py +0 -0
  56. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/dag/__init__.py +0 -0
  57. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/dag/render.py +0 -0
  58. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/decorators.py +0 -0
  59. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/discovery.py +0 -0
  60. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/engine/__init__.py +0 -0
  61. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/engine/agent_rpc.py +0 -0
  62. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/engine/engine.py +0 -0
  63. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/engine/graph.py +0 -0
  64. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/engine/scheduler.py +0 -0
  65. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/engine/sinks.py +0 -0
  66. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/engine/sources.py +0 -0
  67. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/engine/types.py +0 -0
  68. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/engine/watch.py +0 -0
  69. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/engine/worker_pool.py +0 -0
  70. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/executor/AGENTS.md +0 -0
  71. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/executor/CLAUDE.md +0 -0
  72. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/executor/__init__.py +0 -0
  73. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/executor/commit.py +0 -0
  74. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/fingerprint.py +0 -0
  75. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/git.py +0 -0
  76. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/ignore.py +0 -0
  77. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/import_artifact.py +0 -0
  78. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/loaders.py +0 -0
  79. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/metrics.py +0 -0
  80. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/names.py +0 -0
  81. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/outputs.py +0 -0
  82. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/parameters.py +0 -0
  83. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/path_policy.py +0 -0
  84. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/project.py +0 -0
  85. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/py.typed +0 -0
  86. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/registry.py +0 -0
  87. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/remote/AGENTS.md +0 -0
  88. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/remote/__init__.py +0 -0
  89. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/remote/config.py +0 -0
  90. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/remote/git_archive.py +0 -0
  91. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/remote/github.py +0 -0
  92. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/remote/storage.py +0 -0
  93. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/show/__init__.py +0 -0
  94. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/show/common.py +0 -0
  95. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/show/data.py +0 -0
  96. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/show/metrics.py +0 -0
  97. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/show/params.py +0 -0
  98. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/show/plots.py +0 -0
  99. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/skip.py +0 -0
  100. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/stage_def.py +0 -0
  101. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/storage/AGENTS.md +0 -0
  102. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/storage/CLAUDE.md +0 -0
  103. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/storage/__init__.py +0 -0
  104. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/storage/artifact_lock.py +0 -0
  105. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/storage/presentation.py +0 -0
  106. {pivot-0.3.2 → pivot-0.3.4}/src/pivot/storage/state.py +0 -0
  107. {pivot-0.3.2 → 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.2
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.2"
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"
@@ -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
@@ -73,11 +74,15 @@ def _on_disk_matches_expected(
73
74
  if is_dir_hash(expected_hash):
74
75
  if not path.is_dir():
75
76
  return False
76
- actual_hash, _ = cache.hash_directory(path, cache_dir=cache_dir)
77
- else:
78
- if not path.is_file():
79
- return False
80
- 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)
81
86
  return actual_hash == expected_hash["hash"]
82
87
 
83
88
 
@@ -229,9 +234,13 @@ async def _checkout_files_async(
229
234
  for abs_path_str, output_hash in files.items():
230
235
  _ = tg.create_task(restore_one(abs_path_str, output_hash))
231
236
  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
237
+ # Convert unexpected exceptions to a friendly error message, rendering a PivotError's
238
+ # actionable suggestion the same way the top-level CLI error handler does (cli/errors.py).
239
+ messages = [
240
+ cli_errors.format_pivot_error(e) if isinstance(e, exceptions.PivotError) else str(e)
241
+ for e in eg.exceptions
242
+ ]
243
+ raise click.ClickException("\n".join(messages)) from None
235
244
 
236
245
  # Re-raise immediate failures (all of them, aggregated)
237
246
  if immediate_errors:
@@ -154,32 +154,36 @@ 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
 
163
- # Save each file to cache
164
+ # copy_to_cache is idempotent and already checks existence itself; letting it see
165
+ # every entry (not just ones missing from the cache) is what lets it republish an
166
+ # object `pivot track --force` finds cached non-executable by an older pivot or a
167
+ # cold `pivot pull`, when this entry's bytes should be executable.
164
168
  for entry in manifest:
165
169
  file_path = path / entry["relpath"]
166
170
  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)
171
+ cache.copy_to_cache(file_path, file_cache_path, executable=entry["isexec"])
169
172
 
170
173
  pvt_data: track_mod.PvtData = {
171
174
  "path": path.name,
172
- "hash": tree_hash,
175
+ "hash": dir_hash["hash"],
173
176
  "size": total_size,
174
177
  "num_files": num_files,
175
178
  "manifest": manifest,
176
179
  }
180
+ if "empty_dirs" in dir_hash:
181
+ pvt_data["empty_dirs"] = dir_hash["empty_dirs"]
177
182
  else:
178
183
  file_hash, _ = cache.hash_file(path)
179
184
  file_size = path.stat().st_size
180
185
  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)
186
+ cache.copy_to_cache(path, file_cache_path)
183
187
 
184
188
  pvt_data = {
185
189
  "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
 
@@ -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: