feat(snapshot): dedupe rebuild work, lock per key, and prune the store#45
Conversation
Eliminates three sources of waste in the shared snapshot index store
under `<git-common-dir>/tempyr/snapshots/<key>/`:
1. `tempyr index rebuild` short-circuits to a free seed when the shared
snapshot for the current graph state already exists, instead of
rebuilding from scratch and producing identical bytes. Adds `--force`
for corruption recovery.
2. New `SnapshotBuildLock` (per snapshot key, exclusive file lock under
`snapshots/.locks/`) serializes concurrent rebuilds. A second worktree
racing on the same never-seen graph state waits for the first to
publish, then short-circuits through path 1.
3. New `tempyr snapshot {prune,list}` commands implement Nix-style
hybrid retention: pinned (live worktree cursors cross-checked against
`git worktree list`) ∪ recent buffer (`--keep-recent`, default 20) ∪
soft size cap (`--max-size`, default 500 MB). Two-phase rename-then-
remove avoids open-file races on Windows. Auto-runs at the tail of
`index rebuild` and `index update`.
`tempyr doctor` now reports snapshot-store dir count + total bytes with
a `consider \`tempyr snapshot prune\`` hint above the configurable
thresholds. `is_snapshot_key` and `short_path_hash` are exposed from
`tempyr-core::project` so health and prune use identical filtering.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (2)
📝 WalkthroughWalkthroughAdds snapshot-store probing and CLI snapshot commands (prune/list), introduces per-snapshot build locks and bounded rebuild-slot negotiation to reuse or rebuild shared snapshots, wires snapshot metrics into health/doctor output, and updates CLI and ProjectContext wiring to support forced rebuilds and post-operation pruning. ChangesSnapshot store management, rebuild coordination & CLI wiring
Sequence DiagramsequenceDiagram
actor User as User / CLI
participant Main as CLI Dispatcher
participant Neg as negotiate_rebuild_slot
participant Lock as SnapshotBuildLock
participant Snapshot as Snapshot Store
participant Reuse as seed_and_report
participant Rebuild as rebuild_from_scratch
participant Prune as run_prune
User->>Main: tempyr index rebuild [--force]
Main->>Neg: negotiate_rebuild_slot(ctx, force)
alt shared snapshot exists & not forced
Neg->>Snapshot: check shared snapshot presence
Snapshot-->>Neg: found
Neg-->>Main: RebuildSlot::UseExisting
Main->>Reuse: seed_and_report()
Reuse->>Snapshot: open index, read stats
Reuse->>Reuse: optionally embed, publish key
Reuse-->>Main: complete (source: reused)
else needs rebuild
Neg->>Neg: poll for late publish (bounded)
Neg->>Lock: try_acquire_snapshot_build_lock()
alt lock acquired
Lock-->>Neg: Ok(Some(lock))
Neg-->>Main: RebuildSlot::Build(lock)
Main->>Rebuild: rebuild_from_scratch()
Rebuild->>Rebuild: rebuild index, maybe embed
Rebuild->>Snapshot: write/publish snapshot key
Rebuild-->>Main: complete (source: rebuilt)
else lock unavailable (timeout)
Lock-->>Neg: Ok(None) / error
Neg-->>Main: error (lock timeout)
end
end
Main->>Prune: run_prune(..., best-effort)
Prune->>Snapshot: enumerate snapshots, compute pinned set
Prune->>Snapshot: evict per retention, two-phase remove, sweep `.gc-*`
Prune-->>Main: done
Main-->>User: print result / JSON
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Tip 💬 Introducing Slack Agent: The best way for teams to turn conversations into code.Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.
Built for teams:
One agent for your entire SDLC. Right inside Slack. 👉 Get your free trial and get 200 agent minutes per Slack user (a $50 value). Review rate limit: 4/5 reviews remaining, refill in 12 minutes. Comment |
…re-fixes # Conflicts: # crates/tempyr-cli/src/commands/mod.rs
There was a problem hiding this comment.
Actionable comments posted: 4
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@crates/tempyr-cli/src/commands/index_cmd.rs`:
- Around line 217-222: The code opens the local queryable index
(ctx.queryable_index_path() + Index::open) before ensuring the worktree has
copied the latest published snapshot; call the routine that seeds or syncs the
local queryable index from the shared snapshot (e.g., a
ctx.seed_queryable_from_shared_snapshot(snapshot_key) or similar existing
helper) immediately after computing index_path and before calling Index::open so
the local DB exists and stats are current; leave
ctx.write_active_snapshot_key(snapshot_key) and maybe_embed calls in their
current logical order after the seeding step.
- Around line 44-56: The current `if let RebuildSlot::UseExisting { .. } = slot`
drops `slot` (and its `Build(lock)`) before calling `rebuild_from_scratch()`,
releasing the build lock prematurely; replace the `if let` with a `match` on the
result of `negotiate_rebuild_slot(ctx, &shared, force, BUILD_LOCK_WAIT)?` so you
can bind the `Build(lock)` arm to a local name (e.g., `Build(lock)`) and keep
that binding in scope while you call `rebuild_from_scratch(ctx, &graph,
&snapshot_key, json, skip_embeddings)?`, and have the `UseExisting {
built_by_other }` arm immediately return via `seed_and_report(...)`; this
ensures the lock value lives until after `rebuild_from_scratch()` completes.
In `@crates/tempyr-core/src/project.rs`:
- Around line 794-796: is_snapshot_key currently accepts uppercase A-F because
it uses is_ascii_hexdigit(); tighten it to only accept lowercase hex to match
graph_snapshot_key() by validating each byte against '0'..'9' or 'a'..'f'.
Update the is_snapshot_key function (refer to is_snapshot_key and
graph_snapshot_key) so the predicate becomes something like checking each byte
matches b'0'..=b'9' or b'a'..=b'f' (instead of is_ascii_hexdigit()), and
run/update relevant tests expecting lowercase-only snapshot keys.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro
Run ID: f0eada8a-40e2-4a0d-840a-3f03955151b4
⛔ Files ignored due to path filters (1)
Cargo.lockis excluded by!**/*.lock
📒 Files selected for processing (11)
crates/tempyr-cli/Cargo.tomlcrates/tempyr-cli/src/commands/doctor.rscrates/tempyr-cli/src/commands/index_cmd.rscrates/tempyr-cli/src/commands/init.rscrates/tempyr-cli/src/commands/mod.rscrates/tempyr-cli/src/commands/snapshot_cmd.rscrates/tempyr-cli/src/config.rscrates/tempyr-cli/src/main.rscrates/tempyr-core/src/project.rscrates/tempyr-index/Cargo.tomlcrates/tempyr-index/src/health.rs
| let slot = negotiate_rebuild_slot(ctx, &shared, force, BUILD_LOCK_WAIT)?; | ||
| if let RebuildSlot::UseExisting { built_by_other } = slot { | ||
| return seed_and_report( | ||
| ctx, | ||
| &graph, | ||
| &snapshot_key, | ||
| json, | ||
| skip_embeddings, | ||
| built_by_other, | ||
| ); | ||
| } | ||
|
|
||
| rebuild_from_scratch(ctx, &graph, &snapshot_key, json, skip_embeddings)?; |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# Find and examine the RebuildSlot enum definition
rg -t rs "enum RebuildSlot" -A 10Repository: cleak/tempyr
Length of output: 83
🏁 Script executed:
# Read the actual code at lines 44-56 in index_cmd.rs
sed -n '40,65p' crates/tempyr-cli/src/commands/index_cmd.rsRepository: cleak/tempyr
Length of output: 934
🏁 Script executed:
# Check the broader context of the run_rebuild function
sed -n '1,100p' crates/tempyr-cli/src/commands/index_cmd.rs | head -80Repository: cleak/tempyr
Length of output: 3077
🏁 Script executed:
rg "enum RebuildSlot" -A 12 --type-list | head -50Repository: cleak/tempyr
Length of output: 446
🏁 Script executed:
# Search correctly across rust files
find . -name "*.rs" -path "*/commands/index_cmd.rs" | head -1 | xargs grep -A 12 "enum RebuildSlot"Repository: cleak/tempyr
Length of output: 608
🏁 Script executed:
# Get the full context of the run_rebuild function
sed -n '30,75p' crates/tempyr-cli/src/commands/index_cmd.rsRepository: cleak/tempyr
Length of output: 1508
🏁 Script executed:
# Check how SnapshotBuildLock is defined - this is critical for understanding the lock lifetime
rg "struct SnapshotBuildLock|pub struct SnapshotBuildLock" -A 5Repository: cleak/tempyr
Length of output: 412
🏁 Script executed:
# Check if there are any tests for run_rebuild to understand expected behavior
rg "run_rebuild|negotiate_rebuild_slot" -B 2 -A 8 | head -100Repository: cleak/tempyr
Length of output: 7185
🏁 Script executed:
# Verify the return type of negotiate_rebuild_slot
rg "fn negotiate_rebuild_slot" -A 10Repository: cleak/tempyr
Length of output: 824
Keep the build lock alive through rebuild_from_scratch().
When if let RebuildSlot::UseExisting { .. } = slot doesn't match, Rust drops the entire slot value (including RebuildSlot::Build(lock)) at the end of the if let statement. The build lock is released before rebuild_from_scratch() runs, allowing competing worktrees to acquire the lock and rebuild the same snapshot concurrently.
Use a match statement to extract the lock into a binding, keeping it alive until after rebuild_from_scratch() completes.
Suggested fix
- let slot = negotiate_rebuild_slot(ctx, &shared, force, BUILD_LOCK_WAIT)?;
- if let RebuildSlot::UseExisting { built_by_other } = slot {
- return seed_and_report(
- ctx,
- &graph,
- &snapshot_key,
- json,
- skip_embeddings,
- built_by_other,
- );
- }
-
- rebuild_from_scratch(ctx, &graph, &snapshot_key, json, skip_embeddings)?;
+ let build_lock = match negotiate_rebuild_slot(ctx, &shared, force, BUILD_LOCK_WAIT)? {
+ RebuildSlot::UseExisting { built_by_other } => {
+ return seed_and_report(
+ ctx,
+ &graph,
+ &snapshot_key,
+ json,
+ skip_embeddings,
+ built_by_other,
+ );
+ }
+ RebuildSlot::Build(lock) => lock,
+ };
+
+ rebuild_from_scratch(ctx, &graph, &snapshot_key, json, skip_embeddings)?;
+ drop(build_lock);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@crates/tempyr-cli/src/commands/index_cmd.rs` around lines 44 - 56, The
current `if let RebuildSlot::UseExisting { .. } = slot` drops `slot` (and its
`Build(lock)`) before calling `rebuild_from_scratch()`, releasing the build lock
prematurely; replace the `if let` with a `match` on the result of
`negotiate_rebuild_slot(ctx, &shared, force, BUILD_LOCK_WAIT)?` so you can bind
the `Build(lock)` arm to a local name (e.g., `Build(lock)`) and keep that
binding in scope while you call `rebuild_from_scratch(ctx, &graph,
&snapshot_key, json, skip_embeddings)?`, and have the `UseExisting {
built_by_other }` arm immediately return via `seed_and_report(...)`; this
ensures the lock value lives until after `rebuild_from_scratch()` completes.
| let kept_bytes: u64 = entries | ||
| .iter() | ||
| .filter(|e| kept.contains(&e.snapshot_key)) | ||
| .map(|e| e.bytes) | ||
| .sum(); | ||
| let mut running = kept_bytes; | ||
| let mut kept_under_cap = 0; | ||
| let mut evict: Vec<&SnapshotEntry> = Vec::new(); | ||
| let mut unkept: Vec<&SnapshotEntry> = entries | ||
| .iter() | ||
| .filter(|e| !kept.contains(&e.snapshot_key)) | ||
| .collect(); | ||
| unkept.sort_by_key(|e| std::cmp::Reverse(e.modified_secs)); | ||
| for entry in &unkept { | ||
| if running + entry.bytes <= opts.max_size_bytes { |
There was a problem hiding this comment.
Exclude pinned snapshots from cap accounting.
Line 217 initializes the cap budget from all kept entries, so pinned snapshots still consume --max-size headroom. That contradicts the CLI contract for snapshot prune and will evict unpinned history too aggressively when several live worktrees have pins.
Suggested fix
- let kept_bytes: u64 = entries
+ let pinned_bytes: u64 = entries
+ .iter()
+ .filter(|e| pinned.contains(&e.snapshot_key))
+ .map(|e| e.bytes)
+ .sum();
+ let counted_bytes: u64 = entries
.iter()
- .filter(|e| kept.contains(&e.snapshot_key))
+ .filter(|e| kept.contains(&e.snapshot_key) && !pinned.contains(&e.snapshot_key))
.map(|e| e.bytes)
.sum();
- let mut running = kept_bytes;
+ let mut running = counted_bytes;
@@
- total_bytes_after_estimate: running,
+ total_bytes_after_estimate: pinned_bytes + running,There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@crates/tempyr-index/src/health.rs`:
- Around line 442-469: The current traversal in health.rs silently ignores IO
errors (using read.flatten() and multiple continue paths) which yields
misleading partial snapshot totals; change the logic so any failure reading the
snapshots_root entries, calling entry.file_type(), iterating walkdir::WalkDir,
or obtaining sub.metadata() causes an early return of (None, None) (rather than
continue), and only accumulate count/bytes when all operations succeed; update
the iteration over read (the result of std::fs::read_dir(snapshots_root)),
checks around entry.file_type(), the WalkDir iteration, and the meta =
sub.metadata()/meta.is_file() branch to detect and propagate errors back as
(None, None) while keeping the snapshots_root, read, entry, walkdir::WalkDir,
meta.is_file(), bytes and count variables as referenced.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro
Run ID: 830f8a67-400b-40eb-9efc-c19c93b3ee35
⛔ Files ignored due to path filters (1)
Cargo.lockis excluded by!**/*.lock
📒 Files selected for processing (4)
crates/tempyr-cli/Cargo.tomlcrates/tempyr-cli/src/commands/mod.rscrates/tempyr-index/Cargo.tomlcrates/tempyr-index/src/health.rs
Three fixes from review on PR #45, plus a regression test pinning the if-let drop semantics that one piece of feedback misidentified as a bug. - `seed_and_report` now re-seeds the local active.db from the shared snapshot before opening it. Without this, a `built_by_other = true` outcome (concurrent builder published during our wait) would update the worktree cursor to the new key while leaving a stale local index on disk. A subsequent query that fell through to the active path (e.g. after the shared snapshot was pruned) would then read stale bytes against a cursor that claimed they were current. Narrow window but real. - `project::is_snapshot_key` now rejects uppercase A–F. Snapshot keys are produced by BLAKE3 `to_hex()` which always emits lowercase, so any uppercase-named directory under `snapshots/` was created by some foreign process. Treating it as a snapshot would let `tempyr snapshot prune` evict it. Added a paired test that the predicate accepts whatever `graph_snapshot_key()` produces, so the two can't drift. - `health::probe_snapshot_store` now returns `(None, None)` on any IO failure during the walk instead of silently undercounting via `read.flatten()` and per-entry `continue`s. An undercount that hides a multi-GB snapshot store from the doctor's prune hint is worse than a clear "unavailable" signal. Concurrent prune (rename-then-remove) can briefly trigger this; a re-run after prune completes succeeds. - New `build_lock_survives_if_let_match_against_use_existing_arm` test empirically pins the Rust drop semantics in `run_rebuild`: the `if let RebuildSlot::UseExisting { .. } = slot { return ... }` pattern leaves `slot` (and any `Build(SnapshotBuildLock)` it carries) alive for the rest of the function, so the lock is held through `rebuild_from_scratch`. A future refactor that moves out of `slot` in the matched arm would fail this test. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The .agents/ dir was already untracked before this branch — it's local agent state, not part of the snapshot store work. Got swept in by a `git add -A` in the prior commit; removing here. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@crates/tempyr-core/src/project.rs`:
- Around line 98-100: The snapshot_build_lock_path public helper must reject
non-canonical keys: call is_snapshot_key(snapshot_key) at the top of
snapshot_build_lock_path and return an error instead of blindly joining
unvalidated input (or make the helper non-public); change the signature to
return Result<PathBuf, E> (use the crate's existing error type) and return Err
when validation fails, and apply the same validation/signature change to the
other public lock/path helpers referenced around the same area (the functions
that call snapshot_locks_dir and build lock filenames) so no unvalidated
snapshot_key can produce a path outside the locks directory.
In `@crates/tempyr-index/src/health.rs`:
- Around line 443-446: The probe_snapshot_store function currently returns
(None, None) when snapshots_root.is_dir() is false, which makes a missing
snapshots directory look like a probe failure; change probe_snapshot_store to
treat a missing snapshots_root as an empty store and return (Some(0), Some(0))
instead of (None, None). Locate the probe_snapshot_store function and update the
early-return branch that checks snapshots_root.is_dir() so it returns counts of
zero (0 dirs and 0 bytes) for a non-existent directory while keeping the
existing logic for when the directory exists.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro
Run ID: 9ed1bebc-c260-426e-8285-2f465803e338
📒 Files selected for processing (3)
crates/tempyr-cli/src/commands/index_cmd.rscrates/tempyr-core/src/project.rscrates/tempyr-index/src/health.rs
| fn negotiate_rebuild_slot( | ||
| ctx: &ProjectContext, | ||
| shared: &Path, | ||
| force: bool, | ||
| wait_budget: Duration, | ||
| ) -> anyhow::Result<RebuildSlot> { | ||
| if !force && shared.exists() { | ||
| return Ok(RebuildSlot::UseExisting { | ||
| built_by_other: false, | ||
| }); | ||
| } | ||
|
|
||
| if let Some(lock) = ctx.try_acquire_snapshot_build_lock()? { | ||
| if !force && shared.exists() { | ||
| // Another process won the race and published while we were | ||
| // taking the lock. Release and use what they built. | ||
| drop(lock); | ||
| return Ok(RebuildSlot::UseExisting { | ||
| built_by_other: true, | ||
| }); | ||
| } | ||
| return Ok(RebuildSlot::Build(lock)); | ||
| } | ||
|
|
||
| // Lock is held by another rebuild. Wait for them to publish. | ||
| wait_for_snapshot(shared, wait_budget); | ||
| if !force && shared.exists() { | ||
| return Ok(RebuildSlot::UseExisting { | ||
| built_by_other: true, | ||
| }); | ||
| } | ||
|
|
||
| // Other builder didn't publish in time. Block on the lock so the work | ||
| // is still serialized — better to wait than to double-build. | ||
| let lock = acquire_build_lock_blocking(ctx, wait_budget)?; | ||
| if !force && shared.exists() { | ||
| drop(lock); | ||
| return Ok(RebuildSlot::UseExisting { | ||
| built_by_other: true, | ||
| }); | ||
| } | ||
| Ok(RebuildSlot::Build(lock)) |
There was a problem hiding this comment.
--force still leaves the old shared snapshot in place.
The new force path correctly bypasses reuse, but it still publishes through the normal snapshot publisher. In crates/tempyr-core/src/project.rs, publish_snapshot_from() returns immediately when the shared snapshot already exists, so this rebuild only repairs the worktree-local DB. The shared snapshot that other worktrees reuse remains unchanged, which breaks the advertised corruption-recovery behavior.
You need a force-aware publish/replace path under the held per-key lock so the rebuilt index actually replaces the existing shared snapshot atomically.
Also applies to: 128-149
Two more fixes from review on PR #45. - `SnapshotBuildLock::try_acquire` now validates `snapshot_key` against `is_snapshot_key()` before doing IO. The path helpers (`snapshot_locks_dir`, `snapshot_build_lock_path`) are pure path construction and would happily produce a path outside the locks directory if given input like `"../../etc/passwd"`. All current internal callers source the key from `IndexLayout::snapshot_key()` (always BLAKE3 hex), but the function is `pub` so external code could pass arbitrary input. Validation at the IO boundary closes that surface without splaying `Result` returns across every path helper. Test: `snapshot_build_lock_rejects_path_traversal_keys`. - `health::probe_snapshot_store` now distinguishes "no snapshot store yet" from "probe failed". A missing snapshots directory returns `(Some(0), Some(0))` so `tempyr doctor` shows `snapshot store: 0 dirs, 0 B (ok)` on a fresh project instead of silently suppressing the line. Real anomalies (path exists but is not a directory, IO errors mid-walk) still return `(None, None)` so doctor's render can signal "unavailable". Tests: missing-dir, non-directory, and a fresh test that the canonical-snapshot filter excludes `.locks` and `.gc-*`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
Eliminates three sources of waste in the shared snapshot index store at
<git-common-dir>/tempyr/snapshots/<key>/. On a 30-worktree user the snapshot dir grew to 1.6 GB / 884 dirs because everytempyr index rebuildrewrote bytes that another worktree had already produced, and there was no GC.What changes
tempyr index rebuildshort-circuits when the snapshot already exists.index_cmd::run_rebuildnow seeds fromsnapshots/<key>/index.dband returns instead of re-tokenizing the corpus, re-running FTS5, and re-publishing identical bytes. New--forceflag preserves the old behavior for corruption recovery.Per-snapshot-key build lock. New
SnapshotBuildLock(sameFile::try_lockpattern as the existingPublisherLock) takes an exclusive file lock atsnapshots/.locks/<key>.build.lock. A second worktree racing on the same never-seen graph state sees the lock contended, waits for the snapshot to land, then short-circuits through path 1 — never double-builds.tempyr snapshot {prune,list}with Nix-style hybrid retention:<shared_root>/worktrees/<wt-id>/snapshot-key.txtcursor (cross-checked againstgit worktree list --porcelainso dangling cursors from deleted worktrees don't keep dead snapshots alive).--keep-recentnewest by mtime (default 20) above pinned.--max-size(default 500 MB) — newest unpinned win the headroom; older oversized ones evict..gc-<key>-<pid>-<ts>/thenremove_dir_all, with sweep of orphaned stubs from prior runs (handles Windows EBUSY when a long-running query holds the file open).index rebuildandindex update(best-effort, errors swallowed).Plus
tempyr doctornow reports snapshot-store dir count + total bytes with a hint:Live verification on the originating repo
tempyr index rebuild(no flag): `Index reused from existing snapshot: 354 nodes, 872 edges, 354 FTS entries`. Embeddings: 0 fetched, 354 cached. Zero work.Behavior notes
tempyr index rebuildno longer always rewrites. This is the headline behavior change. Existing callers that ran rebuild expecting fresh bytes get a no-op when the snapshot is already on disk. Use `--force` to recover from corruption (help text calls this out).Test plan
🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
tempyr snapshot prune(size/recency retention) andtempyr snapshot list(JSON or human).--forceoption; rebuild/update JSON now includes asourcefield (rebuilt vs reused).Improvements
doctornow reports snapshot store usage with pruning hints.Chores