repo/repository_thread_materialize.rs
1// SPDX-License-Identifier: Apache-2.0
2//! Thread-level materialization: resolve a thread → state → tree,
3//! materialize the tree to disk (clonefile-first via the existing
4//! `Repository::materialize_tree`), and write a [`ThreadManifest`]
5//! sidecar that captures the per-file stat-cache for fast subsequent
6//! `heddle capture` scans.
7//!
8//! This is the day-one default workspace shape for lightweight
9//! threads on reflink-capable filesystems (see
10//! `docs/design/clonefile-threads.md`). Reads off the materialized
11//! tree are vanilla `read(2)` against real APFS/btrfs files — no
12//! userspace FS callbacks in the hot path. Disk usage is the
13//! ~zero-cost clonefile share until the agent diverges blocks.
14
15use std::{
16 collections::{BTreeMap, BTreeSet},
17 fs,
18 path::{Path, PathBuf},
19};
20
21use chrono::{DateTime, Utc};
22use objects::{
23 lock::RepositoryLockExt,
24 object::{State, StateId, ThreadName, Tree, VisibilityTier},
25 store::ObjectStore,
26};
27use oplog::OpRecord;
28use refs::RefExpectation;
29use tracing::{debug, instrument};
30
31use super::{HeddleError, Repository, Result};
32use crate::{
33 ThreadWorktreeTargetDisposition, ThreadWorktreeTargetError,
34 thread_manifest::{ManifestFile, ThreadManifest, read_manifest, write_manifest},
35 validate_thread_worktree_target,
36 visibility::{AudienceTier, visible},
37};
38
39/// Filename of the operator-local courtesy placeholder written when a
40/// checked-out state's tier is not visible to the operator's audience.
41pub(crate) const COURTESY_STUB_FILENAME: &str = "HEDDLE-EMBARGO.txt";
42
43/// Outcome of the visibility-gated checkout chokepoint
44/// [`Repository::checkout_state_gated`].
45#[derive(Clone, Debug)]
46pub enum CheckoutMaterialization {
47 /// The state was visible to the audience: its real tree was materialized
48 /// to `dest`. Carries the resolved tree so callers can populate a manifest
49 /// without a second store lookup.
50 Materialized { tree: Tree },
51 /// The state was under-tier for the audience: the operator-local courtesy
52 /// stub was written to `dest` and the tracked bytes withheld.
53 Withheld { tier: VisibilityTier },
54}
55
56/// Outcome of [`Repository::capture_thread_from_disk`].
57#[derive(Clone, Copy, Debug, PartialEq, Eq)]
58pub enum ThreadCaptureOutcome {
59 /// The materialized tree matches the existing thread head; no
60 /// new state was written. The manifest was refreshed to reflect
61 /// the latest stat fields (so subsequent captures stay fast even
62 /// if mtimes drifted via `touch`).
63 NoOp,
64 /// A new state was written and the thread head advanced.
65 Captured { state_id: StateId },
66}
67
68fn thread_worktree_target_error(error: ThreadWorktreeTargetError) -> HeddleError {
69 match error {
70 ThreadWorktreeTargetError::Io { source, .. } => HeddleError::Io(source),
71 ThreadWorktreeTargetError::Symlink { path } => HeddleError::Conflict(format!(
72 "thread worktree target '{}' cannot be a symlink",
73 path.display()
74 )),
75 ThreadWorktreeTargetError::NotDirectory { path } => HeddleError::Conflict(format!(
76 "thread worktree target '{}' must be a directory",
77 path.display()
78 )),
79 ThreadWorktreeTargetError::NotEmpty { path } => HeddleError::Conflict(format!(
80 "thread worktree target '{}' is not empty",
81 path.display()
82 )),
83 }
84}
85
86fn prepare_thread_worktree_target(dest: &Path) -> Result<ThreadWorktreeTargetDisposition> {
87 let disposition =
88 validate_thread_worktree_target(dest).map_err(thread_worktree_target_error)?;
89 if disposition == ThreadWorktreeTargetDisposition::Absent {
90 fs::create_dir_all(dest).map_err(HeddleError::Io)?;
91 validate_thread_worktree_target(dest).map_err(thread_worktree_target_error)?;
92 }
93 Ok(disposition)
94}
95
96fn clear_dir_contents(dir: &Path) -> std::io::Result<()> {
97 let metadata = fs::symlink_metadata(dir)?;
98 if metadata.file_type().is_symlink() || !metadata.is_dir() {
99 return Ok(());
100 }
101
102 for entry in fs::read_dir(dir)? {
103 let entry = entry?;
104 let path = entry.path();
105 if entry.file_type()?.is_dir() {
106 fs::remove_dir_all(&path)?;
107 } else {
108 fs::remove_file(&path)?;
109 }
110 }
111 Ok(())
112}
113
114fn cleanup_thread_worktree_target(
115 dest: &Path,
116 disposition: ThreadWorktreeTargetDisposition,
117) -> Result<()> {
118 match clear_dir_contents(dest) {
119 Ok(()) => {}
120 Err(err)
121 if err.kind() == std::io::ErrorKind::NotFound
122 || err.kind() == std::io::ErrorKind::NotADirectory => {}
123 Err(err) => return Err(HeddleError::Io(err)),
124 }
125
126 if disposition == ThreadWorktreeTargetDisposition::Absent {
127 match fs::remove_dir(dest) {
128 Ok(()) => {}
129 Err(err)
130 if err.kind() == std::io::ErrorKind::NotFound
131 || err.kind() == std::io::ErrorKind::NotADirectory => {}
132 Err(err) => return Err(HeddleError::Io(err)),
133 }
134 }
135
136 Ok(())
137}
138
139impl Repository {
140 /// Materialize the captured tree of `thread` to `dest` and write
141 /// a [`ThreadManifest`] sidecar to
142 /// `<heddle_dir>/threads/<thread>/manifest.toml`.
143 ///
144 /// Order of operations:
145 /// 1. Resolve `thread` → `StateId` → `State` → `Tree`.
146 /// 2. Call `Repository::materialize_tree(&tree, dest)` — the
147 /// existing clonefile-first materializer does the heavy
148 /// lifting (loose-uncompressed promotion, parallel writes).
149 /// 3. Walk the materialized tree and capture per-file
150 /// `(hash, inode, mtime_ns, ctime_ns, mode)` into the
151 /// manifest.
152 /// 4. Atomically write the manifest.
153 ///
154 /// The walk step in (3) is a single `stat` per file — sub-ms for
155 /// the 643-file heddle workspace. Doing the walk after
156 /// materialize rather than capturing stats during materialize
157 /// keeps the existing materializer untouched.
158 #[instrument(skip(self), fields(thread = %thread, dest = %dest.display()))]
159 pub fn materialize_thread(
160 &self,
161 thread: &str,
162 dest: &Path,
163 audience: &AudienceTier,
164 ) -> Result<ThreadManifest> {
165 let state_id = self
166 .refs()
167 .resolve(thread)?
168 .ok_or_else(|| HeddleError::Config(format!("unknown thread {thread}")))?;
169 let state = self
170 .store()
171 .get_state(&state_id)?
172 .ok_or_else(|| HeddleError::Config(format!("state for {thread} missing")))?;
173 let target_disposition = prepare_thread_worktree_target(dest)?;
174
175 // Route through the single visibility-gated checkout chokepoint, which
176 // either materializes the real tree or writes the operator-local
177 // courtesy stub. The manifest is this method's own concern (it lives
178 // outside the checkout dir), so it is written here based on the gate
179 // outcome — not in the chokepoint, which `write_isolated_checkout` also
180 // calls without wanting a thread manifest.
181 let result = (|| -> Result<ThreadManifest> {
182 match self.checkout_state_gated(&state_id, &state, dest, audience)? {
183 CheckoutMaterialization::Withheld { tier } => {
184 // Manifest reflects disk truth: no tracked files were
185 // materialized (the placeholder is untracked). `tree_hash`
186 // still names the real embargoed state's tree so the sidecar
187 // identifies which state this checkout stands in for. The
188 // `withheld` flag here is diagnostic only — it records that the
189 // *last* materialize of this thread was withheld, but the
190 // per-thread manifest is clobbered by a sibling worktree of the
191 // same thread. The authoritative, per-worktree non-capturable
192 // signal is the withheld marker written by
193 // `checkout_state_gated`, keyed on the worktree root (heddle#316).
194 let mut manifest =
195 ThreadManifest::new(state_id, state.tree, canonical_worktree_path(dest));
196 manifest.withheld = true;
197 write_manifest(self.heddle_dir(), thread, &manifest)
198 .map_err(HeddleError::Io)?;
199 debug!(
200 thread = %thread,
201 state_id = %state_id,
202 tier = tier.as_str(),
203 "thread checkout rendered courtesy stub (under-tier for audience)"
204 );
205 Ok(manifest)
206 }
207 CheckoutMaterialization::Materialized { tree } => {
208 let mut manifest =
209 ThreadManifest::new(state_id, state.tree, canonical_worktree_path(dest));
210 populate_manifest_from_tree(self, &tree, dest, "", &mut manifest.files)?;
211 write_manifest(self.heddle_dir(), thread, &manifest)
212 .map_err(HeddleError::Io)?;
213 debug!(
214 thread = %thread,
215 state_id = %state_id,
216 files = manifest.files.len(),
217 "thread materialized"
218 );
219 Ok(manifest)
220 }
221 }
222 })();
223
224 if result.is_err() {
225 cleanup_thread_worktree_target(dest, target_disposition)?;
226 }
227
228 result
229 }
230
231 /// THE visibility-gated checkout chokepoint. Resolve `state_id`'s
232 /// effective tier against `audience` and either materialize its real tree
233 /// to `dest` (visible) or write the operator-local courtesy stub and
234 /// withhold the tracked bytes (under-tier).
235 ///
236 /// Every path that serves a *named committed state*'s content to a local
237 /// checkout MUST funnel through here — `materialize_thread` and the CLI's
238 /// `write_isolated_checkout` (`heddle start --path`) both do — so the
239 /// visibility gate cannot be bypassed by a caller reaching for the raw,
240 /// blob-keyed `materialize_tree`. The decision is made HERE, where the
241 /// `StateId` and the audience are both in scope; `materialize_tree`
242 /// carries neither and so cannot make it. `materialize_tree` stays the
243 /// primitive for *computed* trees (merge/cherry-pick results), which are
244 /// not a single named state and carry no audience.
245 ///
246 /// The courtesy stub is a working-tree convenience on bytes the operator
247 /// already holds — NOT a security boundary and NOT a public-mirror surface
248 /// (the public mirror emits absence, spike §5.3).
249 pub fn checkout_state_gated(
250 &self,
251 state_id: &StateId,
252 state: &State,
253 dest: &Path,
254 audience: &AudienceTier,
255 ) -> Result<CheckoutMaterialization> {
256 let tier = self.effective_visibility_tier(state_id).map_err(|e| {
257 HeddleError::Config(format!("resolve visibility for {state_id}: {e:#}"))
258 })?;
259 if !visible(&tier, audience) {
260 fs::create_dir_all(dest).map_err(HeddleError::Io)?;
261 // Canonicalize ONLY after the directory exists. `canonical_worktree_path`
262 // falls back to the raw input when `dest` does not yet resolve (a relative
263 // path, or a path through a not-yet-created symlink), so a pre-creation
264 // canonicalize would key the withheld marker and the `.leaves` record on a
265 // path `capture_thread_from_disk` never resolves to at read-time — the read
266 // canonicalizes the now-existing root, misses the marker, and captures a
267 // withheld checkout as a stub-only tree instead of no-oping. Resolving here,
268 // once `create_dir_all` has made `dest` exist, guarantees the write-time
269 // canonical root equals the read-time one (heddle#316).
270 let canonical = canonical_worktree_path(dest);
271 // Reconcile the root DOWN to the withheld tier: every tracked leaf a
272 // prior materialize of this root wrote must be removed, so the
273 // checkout holds ONLY the courtesy stub — never the very bytes the
274 // gate is withholding. `keep` is empty (the withheld tier permits no
275 // tracked content). `must_remove` additionally names the withheld
276 // state's own tree leaves, so the leak is closed even when no prior
277 // manifest survives for this root (a sibling worktree clobbered it).
278 // The stub itself is untracked and so never in either set (heddle#316
279 // CLASS 1).
280 let mut withheld_leaves = BTreeSet::new();
281 if let Some(tree) = self.store().get_tree(&state.tree)? {
282 collect_tree_leaf_paths(self, &tree, "", &mut withheld_leaves)?;
283 }
284 self.reconcile_materialized_root(dest, &canonical, &BTreeSet::new(), &withheld_leaves)?;
285 // Persist the clobber-proof per-root record: a withheld materialize
286 // leaves ONLY the untracked courtesy stub, so the tracked-leaf set is
287 // empty. Written here so the single chokepoint owns the record for
288 // every funnel path, and so a later reconcile of this root reads an
289 // authoritative empty set instead of falling to the backstop
290 // (heddle#316 CLASS 1).
291 crate::thread_manifest::write_materialized_leaves(
292 self.heddle_dir(),
293 &canonical,
294 &BTreeSet::new(),
295 )
296 .map_err(HeddleError::Io)?;
297 let embargo_until = self
298 .effective_state_visibility(state_id)
299 .map_err(|e| {
300 HeddleError::Config(format!("resolve visibility for {state_id}: {e:#}"))
301 })?
302 .and_then(|record| record.embargo_until);
303 let stub = courtesy_stub_text(&tier, embargo_until);
304 fs::write(dest.join(COURTESY_STUB_FILENAME), stub.as_bytes())
305 .map_err(HeddleError::Io)?;
306 // Record the withheld status keyed by THIS worktree root, not by
307 // thread — a sibling worktree of the same thread materialized at a
308 // visible tier must keep its own capturable status (heddle#316).
309 crate::thread_manifest::mark_withheld_checkout(self.heddle_dir(), &canonical)
310 .map_err(HeddleError::Io)?;
311 return Ok(CheckoutMaterialization::Withheld { tier });
312 }
313
314 let tree = self
315 .store()
316 .get_tree(&state.tree)?
317 .ok_or_else(|| HeddleError::Config(format!("tree for {state_id} missing")))?;
318 self.materialize_tree(&tree, dest)?;
319 // Canonicalize only now that `materialize_tree` (via `create_dir_all`) has made
320 // `dest` exist — same read/write-root agreement as the withheld branch above
321 // (heddle#316).
322 let canonical = canonical_worktree_path(dest);
323 // Reconcile the root UP to the served tier: `materialize_tree` wrote the
324 // real tree's leaves but does NOT remove a stale leaf a prior
325 // materialize of a *different* tree left at this root. `keep` is the set
326 // of leaves the served tree just wrote — any prior tracked leaf NOT in
327 // it is removed, so the root holds exactly this tier's content
328 // (heddle#316 CLASS 1).
329 let mut served_leaves = BTreeSet::new();
330 collect_tree_leaf_paths(self, &tree, "", &mut served_leaves)?;
331 self.reconcile_materialized_root(dest, &canonical, &served_leaves, &BTreeSet::new())?;
332 // Persist the clobber-proof per-root record of exactly the tracked leaves
333 // this visible materialize left on disk, so a later withheld
334 // re-materialize of this root removes precisely them even if a sibling
335 // worktree of the same thread clobbered the per-thread manifest in the
336 // interim (heddle#316 CLASS 1).
337 crate::thread_manifest::write_materialized_leaves(
338 self.heddle_dir(),
339 &canonical,
340 &served_leaves,
341 )
342 .map_err(HeddleError::Io)?;
343 // This root now holds real served bytes: clear any stale withheld marker
344 // a prior under-tier materialize of the same root may have left, so it
345 // can't suppress this worktree's capture (heddle#316).
346 crate::thread_manifest::clear_withheld_checkout(self.heddle_dir(), &canonical)
347 .map_err(HeddleError::Io)?;
348 // Remove any leftover courtesy stub a prior under-tier materialize of the
349 // same root wrote: the stub is untracked, so the reconcile leaf-removal
350 // above leaves it in place. Cosmetic — capture ignores it — but an
351 // authorized re-materialize should leave a clean tree (heddle#316).
352 match fs::remove_file(dest.join(COURTESY_STUB_FILENAME)) {
353 Ok(()) => {}
354 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
355 Err(e) => return Err(HeddleError::Io(e)),
356 }
357 Ok(CheckoutMaterialization::Materialized { tree })
358 }
359
360 /// Reconcile the worktree root at `dest` so it holds EXACTLY the content the
361 /// target tier permits, regardless of what a prior materialization of the
362 /// same root left behind. THE single chokepoint both branches of
363 /// [`Repository::checkout_state_gated`] funnel through to enforce the
364 /// invariant by construction rather than via two opposite one-off cleanups
365 /// (heddle#316 CLASS 1).
366 ///
367 /// Removes every tracked leaf that (a) a prior materialization recorded for
368 /// this root in its clobber-proof per-root **materialized-leaves record**
369 /// (keyed by the canonical worktree root, so a sibling worktree of the same
370 /// thread can never erase it) UNION (b) the caller's `must_remove` set —
371 /// MINUS the `keep` set the target tier permits. Removal is guarded per file
372 /// (`NotFound` ignored) and empty ancestor directories it leaves behind are
373 /// pruned via `remove_dir` (which fails on non-empty dirs, so untracked
374 /// siblings keep their directory alive).
375 ///
376 /// Sourcing the prior leaves from the per-root record — NOT the single
377 /// per-thread `manifest.toml` — is what makes the withheld reduction
378 /// correct-by-construction: the manifest is clobbered the instant a sibling
379 /// worktree of the same thread materializes, which would drop a prior
380 /// *visible* leaf (e.g. an `old-secret.txt` removed before the withheld
381 /// target state) out of the removal set and leak it next to the stub. The
382 /// per-root record is immune to that race (heddle#316 CLASS 1).
383 ///
384 /// Never blanket-`rm -rf`s: only paths sourced from the per-root record /
385 /// `must_remove` are touched, so user-untracked files and `.git`/heddle
386 /// metadata are never removed.
387 fn reconcile_materialized_root(
388 &self,
389 dest: &Path,
390 canonical_root: &Path,
391 keep: &BTreeSet<String>,
392 must_remove: &BTreeSet<String>,
393 ) -> Result<()> {
394 let mut to_remove: BTreeSet<String> = must_remove.clone();
395 match crate::thread_manifest::read_materialized_leaves(self.heddle_dir(), canonical_root)
396 .map_err(HeddleError::Io)?
397 {
398 Some(prior_leaves) => {
399 // Clobber-proof per-root record of exactly the tracked leaves a
400 // prior materialize of THIS root left on disk. Authoritative —
401 // survives a sibling worktree's clobber of the per-thread
402 // manifest.
403 to_remove.extend(prior_leaves);
404 }
405 None => {
406 // Fail-closed backstop: no per-root record yet. Reached only on a
407 // first-ever materialize of this root (nothing prior to remove)
408 // or a root last materialized by a binary predating the per-root
409 // record. Fall back to the best-effort per-thread manifest so an
410 // upgrade-window reconcile still drops a recorded prior tree's
411 // leaves; `must_remove` (the target tier's own leaves) covers the
412 // rest. Strictly safer than trusting `must_remove` alone, and —
413 // like the primary path — touches only recorded leaves, never
414 // untracked/non-heddle files.
415 if let Some(prior) = crate::thread_manifest::manifest_for_worktree_root(
416 self.heddle_dir(),
417 canonical_root,
418 )
419 .map_err(HeddleError::Io)?
420 {
421 to_remove.extend(prior.files.keys().cloned());
422 }
423 }
424 }
425
426 let mut prune_dirs: BTreeSet<PathBuf> = BTreeSet::new();
427 for rel in &to_remove {
428 if keep.contains(rel) {
429 continue;
430 }
431 let path = dest.join(rel);
432 match fs::remove_file(&path) {
433 Ok(()) => {}
434 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
435 Err(e) => return Err(HeddleError::Io(e)),
436 }
437 // Collect ancestor directories (within `dest`) so the now-empty ones
438 // left by the removed leaf can be pruned after the pass.
439 let mut parent = path.parent();
440 while let Some(p) = parent {
441 if p == dest || !p.starts_with(dest) {
442 break;
443 }
444 prune_dirs.insert(p.to_path_buf());
445 parent = p.parent();
446 }
447 }
448
449 // Prune deepest-first so a parent only sees its children already gone.
450 // `remove_dir` errors on a non-empty dir, which we ignore — that is
451 // exactly how an untracked sibling keeps its directory.
452 let mut dirs: Vec<PathBuf> = prune_dirs.into_iter().collect();
453 dirs.sort_by_key(|d| std::cmp::Reverse(d.components().count()));
454 for d in dirs {
455 let _ = fs::remove_dir(&d);
456 }
457 Ok(())
458 }
459
460 /// Remove the per-worktree-root sidecars [`checkout_state_gated`] writes —
461 /// the clobber-proof materialized-leaves record and (if present) the withheld
462 /// marker — for the checkout at `worktree_root`. Both live under the SHARED
463 /// heddle dir keyed by the canonical worktree root, so the atomic `start`
464 /// rollback's checkout-directory rewind never reaches them; a failed-then-
465 /// rolled-back start would otherwise orphan them. Canonicalizes `worktree_root`
466 /// the same way the chokepoint did, so the key matches; the dir must still
467 /// exist at call time (the rollback clears these BEFORE rewinding the dir).
468 /// Idempotent: missing sidecars are a no-op (heddle#316 r11 P2).
469 ///
470 /// [`checkout_state_gated`]: Repository::checkout_state_gated
471 pub fn clear_materialized_root_records(&self, worktree_root: &Path) -> Result<()> {
472 let canonical = canonical_worktree_path(worktree_root);
473 crate::thread_manifest::clear_materialized_leaves(self.heddle_dir(), &canonical)
474 .map_err(HeddleError::Io)?;
475 crate::thread_manifest::clear_withheld_checkout(self.heddle_dir(), &canonical)
476 .map_err(HeddleError::Io)?;
477 Ok(())
478 }
479
480 /// Write the [`ThreadManifest`] sidecar for a worktree that's
481 /// already been materialised to `dest` against `state_id`. Used
482 /// by the CLI's `start` path, which calls `materialize_tree`
483 /// directly via `write_isolated_checkout` and then needs the
484 /// matching manifest written so the rest of the clonefile-thread
485 /// machinery (`heddle status` advisory, `Repository::snapshot`
486 /// auto-detection, `capture_thread_from_disk` fast no-op) sees a
487 /// fully-formed sidecar.
488 ///
489 /// `state_id` is the captured state the worktree was materialised
490 /// against; its tree is resolved and walked to populate the
491 /// manifest's per-file stat-cache entries (one `lstat` per file).
492 /// Atomic write: a torn manifest can't half-land. Idempotent at
493 /// the manifest-key level: rewriting a manifest for the same
494 /// thread is supported (and is what `capture_thread_from_disk`
495 /// does post-capture).
496 #[instrument(skip(self), fields(thread = %thread, dest = %dest.display(), state = %state_id))]
497 pub fn record_thread_manifest(
498 &self,
499 thread: &str,
500 state_id: &StateId,
501 dest: &Path,
502 ) -> Result<ThreadManifest> {
503 let state = self
504 .store()
505 .get_state(state_id)?
506 .ok_or_else(|| HeddleError::Config(format!("state {state_id} missing")))?;
507 let tree = self
508 .store()
509 .get_tree(&state.tree)?
510 .ok_or_else(|| HeddleError::Config(format!("tree for state {state_id} missing")))?;
511 let mut manifest =
512 ThreadManifest::new(*state_id, state.tree, canonical_worktree_path(dest));
513 populate_manifest_from_tree(self, &tree, dest, "", &mut manifest.files)?;
514 crate::thread_manifest::write_manifest(self.heddle_dir(), thread, &manifest)
515 .map_err(HeddleError::Io)?;
516 debug!(
517 thread = %thread,
518 state_id = %state_id,
519 files = manifest.files.len(),
520 "thread manifest recorded post-materialize"
521 );
522 Ok(manifest)
523 }
524
525 /// Record a WITHHELD-consistent manifest sidecar for a worktree whose
526 /// checkout was withheld — the base state's visibility tier was not visible
527 /// to the materializing audience, so [`Repository::checkout_state_gated`]
528 /// wrote ONLY the operator-local courtesy stub and the tracked bytes were
529 /// never materialized.
530 ///
531 /// Mirrors the withheld arm of [`Repository::materialize_thread`]: `tree_hash`
532 /// still names the real (unserved) state's tree so the sidecar identifies
533 /// which state the stub stands in for, but `files` is empty (no tracked leaf
534 /// is on disk) and `withheld = true`. Crucially this does NOT walk/stat the
535 /// real tree against `dest` the way [`Repository::record_thread_manifest`]
536 /// does — those files were intentionally not materialized, so stat-ing them
537 /// would record phantom stat-cache entries (or fail) against a checkout that
538 /// holds only the stub. The CLI's atomic `start` path calls this instead of
539 /// `record_thread_manifest` when the checkout came back withheld, so a start
540 /// on a Private base produces a withheld checkout + a consistent manifest
541 /// rather than erroring (heddle#316 / PR #528 r9 Finding 3).
542 #[instrument(skip(self), fields(thread = %thread, dest = %dest.display(), state = %state_id))]
543 pub fn record_withheld_thread_manifest(
544 &self,
545 thread: &str,
546 state_id: &StateId,
547 dest: &Path,
548 ) -> Result<ThreadManifest> {
549 let state = self
550 .store()
551 .get_state(state_id)?
552 .ok_or_else(|| HeddleError::Config(format!("state {state_id} missing")))?;
553 let mut manifest =
554 ThreadManifest::new(*state_id, state.tree, canonical_worktree_path(dest));
555 manifest.withheld = true;
556 crate::thread_manifest::write_manifest(self.heddle_dir(), thread, &manifest)
557 .map_err(HeddleError::Io)?;
558 debug!(
559 thread = %thread,
560 state_id = %state_id,
561 "withheld thread manifest recorded post-materialize"
562 );
563 Ok(manifest)
564 }
565
566 /// The staged domain commit record for a brand-new materialized-thread
567 /// start. The repo owns the op-record shape so callers don't reconstruct
568 /// `OpRecord::ThreadCreate`'s fields. `manager_snapshot` is `None`: the
569 /// thread record is written by the start's converge step (so there is
570 /// nothing to snapshot at record-construction time — heddle#23 r2). The
571 /// caller stages this as the executor's single commit record (it is NOT
572 /// appended eagerly); the commit marker dedups on the stable
573 /// `transaction_id`.
574 pub fn thread_create_op_record(&self, name: &str, state: StateId) -> OpRecord {
575 OpRecord::ThreadCreate {
576 name: name.to_string(),
577 state,
578 manager_snapshot: None,
579 }
580 }
581
582 /// CAS-guarded rollback of a materialized-thread-start ref forward
583 /// (heddle#356 cid 3333881583).
584 ///
585 /// The forward set the thread ref to `set_value` (the start's base state).
586 /// Undo it ONLY if the ref STILL points there: restore `restore_to` when a
587 /// prior value existed (a re-start that reused the ref), or delete a ref
588 /// this start created (`restore_to == None`). If a concurrent process
589 /// advanced/changed the ref after our forward (a concurrent start or
590 /// crash-recovery), leave their write in place — an unconditional
591 /// reset/delete would clobber it.
592 pub fn cas_guarded_thread_ref_rollback(
593 &self,
594 name: &ThreadName,
595 set_value: StateId,
596 restore_to: Option<StateId>,
597 ) -> Result<()> {
598 // Compare-before-write: bail without touching the ref if it no longer
599 // holds the value our forward set.
600 if self.refs().get_thread(name)? != Some(set_value) {
601 return Ok(());
602 }
603 let result = match restore_to {
604 Some(prior) => {
605 self.refs()
606 .set_thread_cas(name, RefExpectation::Value(set_value), &prior)
607 }
608 None => self
609 .refs()
610 .delete_thread_cas(name, RefExpectation::Value(set_value)),
611 };
612 match result {
613 Ok(()) => Ok(()),
614 // Lost the race between the read above and this CAS: a concurrent
615 // writer advanced the ref. The expectation guard means we wrote
616 // nothing — leave their advance intact (the whole point of the
617 // guard).
618 Err(HeddleError::Conflict(_)) => Ok(()),
619 Err(other) => Err(other),
620 }
621 }
622
623 /// Restore the thread manifest sidecar to its captured pre-start snapshot:
624 /// rewrite the prior `manifest.toml` bytes if one existed, or remove the
625 /// directory this start created. Restoring (not blind-deleting) preserves
626 /// an OLD manifest left by a prior materialization of a reused thread ref
627 /// (heddle#356 cid 3333881561).
628 pub fn restore_thread_manifest(&self, thread: &str, prior: Option<Vec<u8>>) -> Result<()> {
629 match prior {
630 Some(bytes) => {
631 let path = crate::thread_manifest::manifest_path(self.heddle_dir(), thread);
632 if let Some(parent) = path.parent() {
633 fs::create_dir_all(parent).map_err(HeddleError::Io)?;
634 }
635 fs::write(&path, bytes).map_err(HeddleError::Io)
636 }
637 None => crate::thread_manifest::remove_thread_manifest_dir(self.heddle_dir(), thread)
638 .map(|_| ())
639 .map_err(HeddleError::Io),
640 }
641 }
642
643 /// Scan the materialized worktree at `root`, build a fresh tree
644 /// from the on-disk bytes, and (if anything changed) advance
645 /// `thread`'s head to a new state pointing at that tree. The
646 /// manifest is rewritten to reflect the new state and the
647 /// post-capture stat fields.
648 ///
649 /// Returns [`ThreadCaptureOutcome::NoOp`] when the new tree's
650 /// hash equals the manifest's recorded `tree_hash` — the agent
651 /// touched nothing material. Otherwise
652 /// [`ThreadCaptureOutcome::Captured`] with the new state id.
653 ///
654 /// The reason this method exists alongside `Repository::snapshot`
655 /// is two-fold:
656 /// 1. `snapshot` always advances `HEAD`'s currently-attached
657 /// thread. Capture-from-disk targets *a specific thread by
658 /// name*, which is what auto-capture-on-switch needs.
659 /// 2. `snapshot` walks `self.root`. Capture-from-disk walks
660 /// whatever directory the materializer put the thread at —
661 /// managed checkouts under `<repo>/.heddle/threads/<thread>/`,
662 /// which are NOT `self.root`.
663 ///
664 /// Walks `Repository::build_tree` for the slow path so the
665 /// resulting trees are byte-identical to what `heddle capture`
666 /// produces against the same content. A stat-cache fast path
667 /// (see [`stat_cache_no_op`]) short-circuits the common case
668 /// of "switch threads, nothing changed" so the dominant
669 /// auto-capture-on-switch latency is a `stat` walk, not a
670 /// blob rehash.
671 #[instrument(skip(self), fields(thread = %thread, root = %root.display()))]
672 pub fn capture_thread_from_disk(
673 &self,
674 thread: &str,
675 root: &Path,
676 ) -> Result<ThreadCaptureOutcome> {
677 // Repository-wide write lock — same shape as
678 // `snapshot_with_attribution_profiled`. Without it, two
679 // concurrent `thread switch` invocations from sibling
680 // worktrees can race the same source thread: both read
681 // `get_thread(thread)` returning the same parent, both
682 // `put_state` with that parent, both `set_thread` —
683 // result is two leaf states with the same parent, one of
684 // which is orphaned because the ref ends up pointing at
685 // whichever `set_thread` won the race. The manifest write
686 // at step 4 has the same lost-update problem on a smaller
687 // scale. Holding the write lock across the whole
688 // read-modify-write sequence makes the capture atomic with
689 // respect to other state-changing operations.
690 let _lock = self
691 .locker()
692 .write()
693 .map_err(|e| HeddleError::Io(std::io::Error::other(e.to_string())))?;
694
695 let existing_manifest =
696 read_manifest(self.heddle_dir(), thread).map_err(HeddleError::Io)?;
697
698 // 0a. Withheld checkouts are non-capturable. A withheld checkout holds
699 // only the operator-local courtesy stub (the tracked bytes were
700 // withheld because the state's tier is not visible to the
701 // materializing audience). Capturing it would either pull the stub
702 // in as tracked content or — worse — build an empty tree (the stub
703 // is ignored, see `ignore_patterns`) and commit it, wiping the
704 // withheld state's real files. The operator cannot capture content
705 // they were never served, so refuse with a no-op and leave the
706 // thread head where it is (heddle#316).
707 //
708 // The withheld status is keyed by THIS worktree root, not by the
709 // per-thread `manifest.toml` — that single file is clobbered when
710 // the same thread is materialized into a second worktree, so a
711 // manifest-level flag would let an under-tier checkout of one
712 // worktree wrongly suppress an authorized sibling worktree's
713 // capture. The per-root marker (written by `checkout_state_gated`)
714 // scopes the suppression to exactly the worktree that was withheld.
715 if crate::thread_manifest::is_withheld_checkout(
716 self.heddle_dir(),
717 &canonical_worktree_path(root),
718 ) {
719 debug!(thread = %thread, "thread capture skipped (withheld checkout)");
720 return Ok(ThreadCaptureOutcome::NoOp);
721 }
722
723 // 0. Fast no-op via the stat-cache. If every file in the
724 // manifest still exists with the same `(inode, mtime,
725 // ctime, mode)` AND the disk walk turns up no
726 // untracked/new files, we know the tree is byte-identical
727 // to what we materialised. Skip the entire blob-and-tree
728 // rebuild. Typical cost: ~5ms for a 643-file worktree
729 // vs hundreds of ms for the full `build_tree` rehash.
730 if let Some(m) = existing_manifest.as_ref()
731 && stat_cache_no_op(self, m, root)?
732 {
733 debug!(thread = %thread, "thread capture no-op (stat-cache hit)");
734 return Ok(ThreadCaptureOutcome::NoOp);
735 }
736
737 let baseline_tree = match existing_manifest.as_ref() {
738 Some(manifest) => {
739 Some(self.store().get_tree(&manifest.tree_hash)?.ok_or_else(|| {
740 HeddleError::Config(format!(
741 "manifest baseline tree {} missing while capturing thread {thread}",
742 manifest.tree_hash
743 ))
744 })?)
745 }
746 None => None,
747 };
748
749 // 1. Walk the on-disk worktree → fresh Tree (also stores
750 // every blob it sees as a side effect). When we have a
751 // manifest, pass it as a stat-cache so unchanged files
752 // skip the read+hash cycle entirely. Files that DID
753 // change still get the full treatment, so correctness
754 // is preserved; we just avoid the redundant work for
755 // the (usually large) majority.
756 let new_tree = match existing_manifest.as_ref() {
757 Some(m) => {
758 self.build_tree_profiled_with_stat_cache_against(root, baseline_tree.as_ref(), m)?
759 .0
760 }
761 None => {
762 self.build_tree_profiled_against(root, baseline_tree.as_ref())?
763 .0
764 }
765 };
766 let new_tree_hash = self.store().put_tree(&new_tree)?;
767
768 // 2. Content-hash no-op (slow path equivalent of the
769 // stat-cache check above). Hits when stat fields drifted
770 // via `touch` or atime updates even though the bytes
771 // didn't change — refresh the manifest's stat fields so
772 // the next call hits the fast path.
773 if existing_manifest
774 .as_ref()
775 .map(|m| m.tree_hash == new_tree_hash)
776 .unwrap_or(false)
777 {
778 let mut refreshed = existing_manifest.expect("checked Some above");
779 refreshed.files.clear();
780 populate_manifest_from_tree(self, &new_tree, root, "", &mut refreshed.files)?;
781 write_manifest(self.heddle_dir(), thread, &refreshed).map_err(HeddleError::Io)?;
782 debug!(thread = %thread, "thread capture no-op (content-hash refresh)");
783 return Ok(ThreadCaptureOutcome::NoOp);
784 }
785
786 // 3. Real capture. Build a new state parented at the
787 // current thread head (if any), put it, advance the
788 // thread ref.
789 let attribution = self.get_attribution()?;
790 let thread_name = ThreadName::from(thread);
791 let parents = match self.refs().get_thread(&thread_name)? {
792 Some(prev) => vec![prev],
793 None => vec![],
794 };
795 let state = State::new_snapshot(new_tree_hash, parents, attribution);
796 // Auto-sign this thread-materialization capture (heddle#482) via the
797 // authored-state chokepoint, the same as the primary capture path — it
798 // is a real author capture that bypasses `stage_snapshot_objects`. Last
799 // mutation before the write.
800 self.put_authored_state(&state)?;
801 self.refs().set_thread(&thread_name, &state.id())?;
802
803 // 4. Rewrite the manifest to reflect the new state. `root` is
804 // the worktree being captured from — record its canonical
805 // path so the next snapshot can tell whether it's running
806 // inside this same worktree.
807 let mut manifest =
808 ThreadManifest::new(state.id(), new_tree_hash, canonical_worktree_path(root));
809 populate_manifest_from_tree(self, &new_tree, root, "", &mut manifest.files)?;
810 write_manifest(self.heddle_dir(), thread, &manifest).map_err(HeddleError::Io)?;
811
812 debug!(
813 thread = %thread,
814 new_state = %state.id(),
815 files = manifest.files.len(),
816 "thread captured"
817 );
818 Ok(ThreadCaptureOutcome::Captured {
819 state_id: state.id(),
820 })
821 }
822}
823
824/// Recursive helper: for each tree entry under `rel_prefix` inside
825/// the materialized `dest`, walk the captured tree (NOT the disk —
826/// we trust what we just put there) and stat the corresponding file
827/// to fill in the manifest's identity fields.
828///
829/// Using the captured tree as the walk basis is what lets a
830/// manifest entry survive `rm -rf .` later: the file may have
831/// disappeared but we still record what *should* be there per the
832/// captured state. Capture-from-disk decides what to do about
833/// missing files at its own scan time.
834/// Plain-text placeholder a holder sees instead of an under-tier state's
835/// tracked content on their own checkout. ASCII-only, mirrors the redaction
836/// `stub_text` shape. Never travels off-host.
837fn courtesy_stub_text(tier: &VisibilityTier, embargo_until: Option<DateTime<Utc>>) -> String {
838 let mut out = String::with_capacity(256);
839 out.push_str("# Heddle withheld this state's content from your audience.\n");
840 out.push_str(&format!("# visibility-tier: {}\n", tier.as_str()));
841 if let VisibilityTier::TeamScoped { team_id } = tier {
842 out.push_str(&format!("# team: {team_id}\n"));
843 }
844 if let VisibilityTier::Restricted { scope_label } | VisibilityTier::Private { scope_label } =
845 tier
846 {
847 out.push_str(&format!("# scope: {scope_label}\n"));
848 }
849 match embargo_until {
850 Some(when) => out.push_str(&format!("# promotes-at: {}\n", when.to_rfc3339())),
851 None => out.push_str("# promotes-at: (no scheduled promotion)\n"),
852 }
853 out.push_str("# This placeholder is a local courtesy; the bytes are not in this checkout.\n");
854 out
855}
856
857/// Collect every blob/symlink leaf path (worktree-relative, forward-slash
858/// joined) reachable from `tree` into `out`. Used by the checkout reconcile
859/// step to enumerate the tracked content a tier serves (the `keep` set on the
860/// visible path) or withholds (the `must_remove` set on the withheld path),
861/// without touching disk — the path set is derived purely from the tree.
862fn collect_tree_leaf_paths(
863 repo: &Repository,
864 tree: &Tree,
865 rel_prefix: &str,
866 out: &mut BTreeSet<String>,
867) -> Result<()> {
868 use objects::object::EntryType;
869 for entry in tree.entries() {
870 let rel_path = if rel_prefix.is_empty() {
871 entry.name().to_string()
872 } else {
873 format!("{rel_prefix}/{}", entry.name())
874 };
875 match entry.entry_type() {
876 EntryType::Tree => {
877 let Some(tree_hash) = entry.tree_hash() else {
878 continue;
879 };
880 let subtree = repo.store().get_tree(&tree_hash)?.ok_or_else(|| {
881 HeddleError::Config(format!(
882 "subtree {} missing while collecting leaf paths for {rel_path}",
883 tree_hash
884 ))
885 })?;
886 collect_tree_leaf_paths(repo, &subtree, &rel_path, out)?;
887 }
888 EntryType::Blob | EntryType::Symlink | EntryType::Gitlink => {
889 out.insert(rel_path);
890 }
891 // Native child-spool edge: not a worktree leaf, so it has no
892 // materialized path to collect.
893 EntryType::Spoollink => {}
894 }
895 }
896 Ok(())
897}
898
899pub(crate) fn populate_manifest_from_tree(
900 repo: &Repository,
901 tree: &Tree,
902 dest: &Path,
903 rel_prefix: &str,
904 out: &mut BTreeMap<String, ManifestFile>,
905) -> Result<()> {
906 use objects::object::EntryType;
907 for entry in tree.entries() {
908 let rel_path = if rel_prefix.is_empty() {
909 entry.name().to_string()
910 } else {
911 format!("{rel_prefix}/{}", entry.name())
912 };
913 match entry.entry_type() {
914 EntryType::Tree => {
915 let Some(tree_hash) = entry.tree_hash() else {
916 continue;
917 };
918 let subtree = repo.store().get_tree(&tree_hash)?.ok_or_else(|| {
919 HeddleError::Config(format!(
920 "subtree {} missing while populating manifest for {rel_path}",
921 tree_hash
922 ))
923 })?;
924 populate_manifest_from_tree(repo, &subtree, dest, &rel_path, out)?;
925 }
926 EntryType::Blob | EntryType::Symlink => {
927 let on_disk = dest.join(&rel_path);
928 let meta = match fs::symlink_metadata(&on_disk) {
929 Ok(m) => m,
930 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
931 // The materializer didn't put it there. That
932 // shouldn't happen on a clean materialize,
933 // but if it does we skip the entry so the
934 // manifest stays a reflection of disk truth.
935 debug!(
936 path = %rel_path,
937 "manifest population skipped missing file"
938 );
939 continue;
940 }
941 Err(e) => return Err(HeddleError::Io(e)),
942 };
943 let (size, inode, mtime_ns, ctime_ns, mode) =
944 crate::stat_signature::stat_signature(&on_disk, &meta);
945 out.insert(
946 rel_path,
947 ManifestFile {
948 hash: entry.require_content_hash(),
949 size,
950 inode,
951 mtime_ns,
952 ctime_ns,
953 mode,
954 },
955 );
956 }
957 EntryType::Gitlink => {}
958 // Native child-spool edge: nothing materialized to disk.
959 EntryType::Spoollink => {}
960 }
961 }
962 Ok(())
963}
964
965/// Record the manifest's worktree-path field as an *absolute*,
966/// symlink-resolved path. `Repository::snapshot` compares its
967/// `self.root` (also canonicalized) to this value to decide whether
968/// it's running inside the materialized worktree; without
969/// canonicalization a `/tmp/foo` materialize + `/private/tmp/foo`
970/// snapshot would miss the match on macOS.
971///
972/// Falls back to the input path on canonicalize failure — the
973/// comparison may produce a false miss in pathological cases, which
974/// degrades the cache to "always rebuild" instead of corrupting the
975/// manifest. Strictly worse perf, never worse correctness.
976pub(crate) fn canonical_worktree_path(path: &Path) -> PathBuf {
977 fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
978}
979
980/// Stat-cache fast no-op check. Returns `true` when the on-disk
981/// worktree is byte-identical to what `manifest` describes — every
982/// manifest file present at its recorded `(inode, mtime, ctime,
983/// mode)`, no untracked files, no deletions.
984///
985/// Pattern: same as git's index `assume-unchanged` fast path. The
986/// stat fields are populated by `populate_manifest_from_tree` at
987/// materialise time; clonefile/copy operations preserve the
988/// destination's inode for the lifetime of the file, so a single
989/// `stat` per file is sufficient to detect any modification.
990///
991/// Performance: ~5 ms for a 643-file worktree (single `stat` per
992/// file + B-tree lookup). The slow path (`build_tree`) reads and
993/// hashes every file, ~100s of ms for the same fixture.
994///
995/// Returns `Ok(false)` on ANY uncertainty — a stat call failed, a
996/// file in the manifest is missing, an untracked file showed up,
997/// or any single field mismatched. Callers fall through to the
998/// slow `build_tree` path, which is always correct.
999/// Walk the captured tree named by `manifest.tree_hash` and collect
1000/// every subdirectory's relative path (forward-slash joined,
1001/// relative to the tree root, no leading or trailing slashes).
1002/// Source of truth for [`stat_cache_no_op`]'s directory leg —
1003/// includes tree-only empty directories that a `manifest.files`
1004/// ancestors-derived set would miss.
1005fn collect_expected_dirs(
1006 repo: &Repository,
1007 manifest: &ThreadManifest,
1008) -> Result<std::collections::HashSet<String>> {
1009 use std::collections::HashSet;
1010 let mut set: HashSet<String> = HashSet::new();
1011 let Some(tree) = repo.store().get_tree(&manifest.tree_hash)? else {
1012 // Tree missing from the store would be a serious anomaly —
1013 // surface it so the caller bails to the slow path which will
1014 // re-derive everything from the worktree.
1015 return Err(HeddleError::Config(format!(
1016 "tree {} referenced by manifest is missing",
1017 manifest.tree_hash
1018 )));
1019 };
1020 collect_subdirs_into(repo, &tree, "", &mut set)?;
1021 Ok(set)
1022}
1023
1024fn collect_subdirs_into(
1025 repo: &Repository,
1026 tree: &objects::object::Tree,
1027 rel_prefix: &str,
1028 out: &mut std::collections::HashSet<String>,
1029) -> Result<()> {
1030 use objects::object::EntryType;
1031 for entry in tree.entries() {
1032 if entry.entry_type() != EntryType::Tree {
1033 continue;
1034 }
1035 let rel = if rel_prefix.is_empty() {
1036 entry.name().to_string()
1037 } else {
1038 format!("{rel_prefix}/{}", entry.name())
1039 };
1040 let Some(tree_hash) = entry.tree_hash() else {
1041 continue;
1042 };
1043 let subtree = repo.store().get_tree(&tree_hash)?.ok_or_else(|| {
1044 HeddleError::Config(format!(
1045 "subtree {} missing while collecting expected dirs at {rel}",
1046 tree_hash
1047 ))
1048 })?;
1049 out.insert(rel.clone());
1050 collect_subdirs_into(repo, &subtree, &rel, out)?;
1051 }
1052 Ok(())
1053}
1054
1055/// Recursive `read_dir` worker for the stat-cache no-op predicate.
1056/// Returns `Ok(false)` to bail to the slow path (anything unexpected,
1057/// any stat mismatch); `Ok(true)` to continue the walk. Final
1058/// presence checks (`seen.len() == manifest.files.len()` etc.) live
1059/// in the caller; this fn only flags incremental mismatches.
1060///
1061/// Why hand-roll rather than reuse `ignore::WalkBuilder`: the walker
1062/// crate buffers entries, sorts them for determinism, calls
1063/// `metadata()` to populate its own `DirEntry`, and runs the gitignore
1064/// pipeline per directory even with every `git_*` flag turned off.
1065/// All of that is wasted on this predicate, which already has its own
1066/// `WorktreeIgnoreMatcher` and only needs `symlink_metadata` on each
1067/// file. A bare `read_dir` recursion is ≈3× faster on the 10k-file
1068/// fixture and matches `build_tree`'s ignore semantics exactly
1069/// because we go through the same matcher.
1070fn walk_for_no_op(
1071 root: &Path,
1072 cur: &Path,
1073 manifest: &ThreadManifest,
1074 expected_dirs: &std::collections::HashSet<String>,
1075 ignore_matcher: &crate::worktree_ignore::WorktreeIgnoreMatcher,
1076 seen: &mut std::collections::HashSet<String>,
1077 seen_dirs: &mut std::collections::HashSet<String>,
1078) -> Result<bool> {
1079 let entries = match fs::read_dir(cur) {
1080 Ok(it) => it,
1081 // A directory we can't read means we've lost certainty about
1082 // its contents — fall through to the slow path.
1083 Err(_) => return Ok(false),
1084 };
1085 for entry in entries {
1086 let entry = match entry {
1087 Ok(e) => e,
1088 Err(_) => return Ok(false),
1089 };
1090 let path = entry.path();
1091 let Ok(rel) = path.strip_prefix(root) else {
1092 return Ok(false);
1093 };
1094 let rel_str = rel.to_string_lossy().into_owned();
1095 let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
1096 return Ok(false);
1097 };
1098
1099 // Run the ignore matcher *first*, before consulting the
1100 // manifest. The previous "manifest-first" dispatch
1101 // accepted any manifest hit without re-checking the
1102 // matcher, which silently false-passed if the user had
1103 // tightened `.heddleignore` (or the in-config ignore set)
1104 // between materialise and this capture — `build_tree`
1105 // would now exclude the previously-tracked path and
1106 // produce a different tree, but the predicate said
1107 // "no-op". Always running the matcher first costs a
1108 // pattern check per entry but is what makes the
1109 // predicate's output match what `build_tree` would do.
1110 //
1111 // Three outcomes from the matcher:
1112 // * Pruned + in manifest → ignore-config drift; bail
1113 // to slow path so the new tree reflects the new
1114 // exclusion.
1115 // * Pruned + not in manifest → genuinely ignored;
1116 // silently skip without recursing.
1117 // * Not pruned → standard manifest / new-entry
1118 // dispatch below.
1119 // `should_prune_directory_child` matches the production
1120 // walker's per-entry probe (`worktree_walk.rs`). It calls
1121 // `matched_relative(path, is_dir=true)` so gitignore rules
1122 // with trailing `/` still fire, and the same patterns
1123 // exclude both file and directory entries — same behaviour
1124 // `build_tree` would observe at materialise time.
1125 let pruned = ignore_matcher.should_prune_absolute_path(&path)
1126 || ignore_matcher.should_prune_directory_child(cur, name);
1127 if pruned {
1128 if manifest.files.contains_key(&rel_str) {
1129 // The matcher now wants this path excluded, but
1130 // it's in the manifest from materialise time.
1131 // Ignore-config drift — let the slow path
1132 // rebuild the tree without it.
1133 return Ok(false);
1134 }
1135 continue;
1136 }
1137
1138 // Not pruned. Manifest lookup is the fast path for
1139 // tracked files; un-tracked entries fall through to
1140 // dir-recursion / new-file detection below.
1141 if let Some(manifest_entry) = manifest.files.get(&rel_str) {
1142 // `symlink_metadata` (not `metadata`) so a symlink
1143 // doesn't transparently follow into the target's
1144 // inode.
1145 let meta = match fs::symlink_metadata(&path) {
1146 Ok(m) => m,
1147 Err(_) => return Ok(false),
1148 };
1149 let (size, inode, mtime_ns, ctime_ns, mode) =
1150 crate::stat_signature::stat_signature(&path, &meta);
1151 let stat = ManifestFile {
1152 hash: manifest_entry.hash,
1153 size,
1154 inode,
1155 mtime_ns,
1156 ctime_ns,
1157 mode,
1158 };
1159 if !stat.matches(manifest_entry) {
1160 return Ok(false);
1161 }
1162 seen.insert(rel_str);
1163 continue;
1164 }
1165
1166 let file_type = match entry.file_type() {
1167 Ok(ft) => ft,
1168 Err(_) => return Ok(false),
1169 };
1170 if file_type.is_dir() {
1171 // Directory leg: any directory not in `expected_dirs`
1172 // is an addition since materialise. Bail; the slow
1173 // path will incorporate it.
1174 if !expected_dirs.contains(&rel_str) {
1175 return Ok(false);
1176 }
1177 seen_dirs.insert(rel_str);
1178 if !walk_for_no_op(
1179 root,
1180 &path,
1181 manifest,
1182 expected_dirs,
1183 ignore_matcher,
1184 seen,
1185 seen_dirs,
1186 )? {
1187 return Ok(false);
1188 }
1189 continue;
1190 }
1191
1192 // A non-ignored, non-directory entry that's not in the
1193 // manifest is a new file. Bail to the slow path which
1194 // will rebuild the tree with the new entry.
1195 return Ok(false);
1196 }
1197 Ok(true)
1198}
1199
1200fn stat_cache_no_op(repo: &Repository, manifest: &ThreadManifest, root: &Path) -> Result<bool> {
1201 use std::collections::HashSet;
1202
1203 let ignore_patterns = repo.ignore_patterns()?;
1204 let nested_exclusions = repo.nested_thread_worktree_exclusions(root)?;
1205 let ignore_matcher = crate::worktree_ignore::WorktreeIgnoreMatcher::new(&ignore_patterns)
1206 .with_nested_worktree_exclusions(nested_exclusions);
1207
1208 // Manifests only record files+symlinks, but Heddle's tree
1209 // builder materialises empty directories as their own tree
1210 // entries. So a no-op predicate that only checks `manifest.files`
1211 // would miss "user added or removed an empty directory" —
1212 // `seen.len() == manifest.files.len()` is still true on the file
1213 // side, but the on-disk tree no longer matches what `build_tree`
1214 // would produce.
1215 //
1216 // Source of truth for the expected directory set is the captured
1217 // tree itself (the one the manifest's `tree_hash` names), not
1218 // the manifest's file ancestors. Two reasons:
1219 //
1220 // 1. *Tree-only empty directories.* A `Tree` entry with no
1221 // files beneath it is invisible from a `manifest.files`
1222 // ancestors-walk — the file set is empty, so every
1223 // ancestor it would contribute is missing. Removing a
1224 // legit empty leaf dir would still false-pass.
1225 // 2. *Future schema drift.* Files in `manifest.files` may
1226 // use slash-normalised relative paths that don't exactly
1227 // match how `Tree::entries` names subdirs on every
1228 // platform; walking the tree directly avoids the
1229 // double-encoding hazard.
1230 //
1231 // Cost is ~one `get_tree` per subdir of the captured tree.
1232 // For the typical thread (a few hundred dirs) that's a small
1233 // number of memory-mapped object reads; on the predicate's
1234 // hot path it's bounded by the tree's directory fan-out, not
1235 // file count.
1236 let expected_dirs: HashSet<String> = match collect_expected_dirs(repo, manifest) {
1237 Ok(s) => s,
1238 // Any error walking the tree → conservatively bail to the
1239 // slow path. `Ok(false)` keeps correctness; the worst case
1240 // is a wasted full rebuild.
1241 Err(_) => return Ok(false),
1242 };
1243
1244 // Walk the worktree. For every file we see, check it against the
1245 // manifest. Track which manifest paths we've actually seen so we
1246 // can detect deletions afterwards.
1247 //
1248 // Custom `read_dir` recursion instead of `ignore::WalkBuilder`:
1249 // the walker crate is fast on its own but the per-entry overhead
1250 // adds up at 10k+ files (it buffers, sorts, double-stats, and
1251 // re-applies the ignore stack for every dir). For this hot
1252 // predicate we only need: a `readdir` per directory, one
1253 // `symlink_metadata` per file, and the same ignore-matcher
1254 // check `build_tree` runs. The std-only recursion below
1255 // measured ≈3× faster on the 10k-file fixture (no per-entry
1256 // double-stat, no buffer churn, fewer allocations).
1257 let mut seen: HashSet<String> = HashSet::with_capacity(manifest.files.len());
1258 let mut seen_dirs: HashSet<String> = HashSet::with_capacity(expected_dirs.len());
1259 if !walk_for_no_op(
1260 root,
1261 root,
1262 manifest,
1263 &expected_dirs,
1264 &ignore_matcher,
1265 &mut seen,
1266 &mut seen_dirs,
1267 )? {
1268 return Ok(false);
1269 }
1270
1271 // Final pass: every manifest entry must have been seen (file
1272 // deletion check) and every manifest-implied directory must
1273 // have been seen (directory deletion check). The dir-side
1274 // check catches `rmdir` of an empty directory that was part
1275 // of the materialised tree — its files are also gone (so the
1276 // file side already declines) but if it had no files to begin
1277 // with the file side alone would false-pass.
1278 if seen.len() != manifest.files.len() {
1279 return Ok(false);
1280 }
1281 if seen_dirs.len() != expected_dirs.len() {
1282 return Ok(false);
1283 }
1284 Ok(true)
1285}
1286
1287#[cfg(test)]
1288mod tests {
1289 use objects::{
1290 object::{Blob, TreeEntry},
1291 util::gitlink_placeholder_bytes,
1292 };
1293 use sley::{ObjectFormat as GitObjectFormat, ObjectId as GitObjectId};
1294 use tempfile::TempDir;
1295
1296 use super::*;
1297 use crate::thread_manifest::read_manifest;
1298
1299 fn gitlink_target_for_tests() -> GitObjectId {
1300 GitObjectId::from_hex(
1301 GitObjectFormat::Sha1,
1302 "1234567890abcdef1234567890abcdef12345678",
1303 )
1304 .unwrap()
1305 }
1306
1307 fn seeded_repo() -> (TempDir, Repository) {
1308 let repo_dir = TempDir::new().unwrap();
1309 let repo = Repository::init_default(repo_dir.path()).unwrap();
1310 fs::write(repo_dir.path().join("file.txt"), b"tracked\n").unwrap();
1311 repo.snapshot(Some("seed".into()), None).unwrap();
1312 (repo_dir, repo)
1313 }
1314
1315 #[test]
1316 fn capture_thread_from_disk_preserves_unchanged_gitlink_when_sibling_changes() {
1317 let repo_dir = TempDir::new().unwrap();
1318 let repo = Repository::init_default(repo_dir.path()).unwrap();
1319 let target = gitlink_target_for_tests();
1320 let note_hash = repo
1321 .store()
1322 .put_blob(&Blob::new(b"before\n".to_vec()))
1323 .unwrap();
1324 let tree = Tree::from_entries(vec![
1325 TreeEntry::file("note.txt", note_hash, false).unwrap(),
1326 TreeEntry::gitlink("vendor", target).unwrap(),
1327 ]);
1328 repo.snapshot_tree_with_attribution_profiled(
1329 tree,
1330 Some("gitlink thread baseline".to_string()),
1331 None,
1332 repo.get_attribution().unwrap(),
1333 )
1334 .unwrap();
1335
1336 let dest = repo_dir.path().join("thread-out");
1337 repo.materialize_thread("main", &dest, &AudienceTier::Internal)
1338 .unwrap();
1339 assert_eq!(
1340 fs::read(dest.join("vendor")).unwrap(),
1341 gitlink_placeholder_bytes(&target)
1342 );
1343
1344 fs::write(dest.join("note.txt"), b"after\n").unwrap();
1345 let outcome = repo.capture_thread_from_disk("main", &dest).unwrap();
1346 let state_id = match outcome {
1347 ThreadCaptureOutcome::Captured { state_id } => state_id,
1348 ThreadCaptureOutcome::NoOp => panic!("sibling edit must capture a new state"),
1349 };
1350 let state = repo
1351 .store()
1352 .get_state(&state_id)
1353 .unwrap()
1354 .expect("captured state");
1355 let captured_tree = repo
1356 .store()
1357 .get_tree(&state.tree)
1358 .unwrap()
1359 .expect("captured tree");
1360
1361 assert_eq!(
1362 captured_tree
1363 .get("vendor")
1364 .expect("vendor gitlink")
1365 .gitlink_target(),
1366 Some(target)
1367 );
1368 let note_hash = captured_tree
1369 .get("note.txt")
1370 .expect("note entry")
1371 .blob_hash()
1372 .expect("note blob");
1373 let note = repo
1374 .store()
1375 .get_blob(¬e_hash)
1376 .unwrap()
1377 .expect("note blob");
1378 assert_eq!(note.content(), b"after\n");
1379 }
1380
1381 #[test]
1382 fn materialize_thread_writes_manifest_with_files() {
1383 let repo_dir = TempDir::new().unwrap();
1384 let repo = Repository::init_default(repo_dir.path()).unwrap();
1385 // Build a small worktree to capture.
1386 fs::write(repo_dir.path().join("Cargo.toml"), b"# a\n").unwrap();
1387 fs::create_dir_all(repo_dir.path().join("src")).unwrap();
1388 fs::write(repo_dir.path().join("src/lib.rs"), b"fn main() {}\n").unwrap();
1389 repo.snapshot(Some("seed".into()), None).unwrap();
1390
1391 let dest = TempDir::new().unwrap();
1392 let manifest = repo
1393 .materialize_thread("main", &dest.path().join("out"), &AudienceTier::Internal)
1394 .unwrap();
1395
1396 assert_eq!(
1397 manifest.schema_version,
1398 crate::thread_manifest::SCHEMA_VERSION
1399 );
1400 // Three files: Cargo.toml, src/lib.rs, plus whatever
1401 // init_default seeded — only assert the ones we wrote
1402 // exist and have plausible stat fields.
1403 let cargo = manifest
1404 .files
1405 .get("Cargo.toml")
1406 .expect("Cargo.toml in manifest");
1407 assert_ne!(cargo.inode, 0);
1408 assert_ne!(cargo.mtime_ns, 0);
1409 let src = manifest
1410 .files
1411 .get("src/lib.rs")
1412 .expect("src/lib.rs in manifest");
1413 assert_ne!(src.inode, 0);
1414
1415 // Manifest persisted to disk.
1416 let loaded = read_manifest(repo.heddle_dir(), "main")
1417 .unwrap()
1418 .expect("manifest on disk");
1419 assert_eq!(loaded.files.len(), manifest.files.len());
1420 assert_eq!(
1421 loaded.files["Cargo.toml"].inode,
1422 manifest.files["Cargo.toml"].inode
1423 );
1424 }
1425
1426 #[test]
1427 fn materialize_thread_creates_absent_target() {
1428 let (_repo_dir, repo) = seeded_repo();
1429 let dest_holder = TempDir::new().unwrap();
1430 let dest = dest_holder.path().join("out");
1431
1432 repo.materialize_thread("main", &dest, &AudienceTier::Internal)
1433 .unwrap();
1434
1435 assert!(dest.is_dir());
1436 assert_eq!(
1437 fs::read_to_string(dest.join("file.txt")).unwrap(),
1438 "tracked\n"
1439 );
1440 }
1441
1442 #[test]
1443 fn materialize_thread_adopts_empty_directory() {
1444 let (_repo_dir, repo) = seeded_repo();
1445 let dest_holder = TempDir::new().unwrap();
1446 let dest = dest_holder.path().join("out");
1447 fs::create_dir(&dest).unwrap();
1448
1449 repo.materialize_thread("main", &dest, &AudienceTier::Internal)
1450 .unwrap();
1451
1452 assert!(dest.is_dir());
1453 assert_eq!(
1454 fs::read_to_string(dest.join("file.txt")).unwrap(),
1455 "tracked\n"
1456 );
1457 }
1458
1459 #[test]
1460 fn materialize_thread_rejects_non_empty_directory() {
1461 let (_repo_dir, repo) = seeded_repo();
1462 let dest_holder = TempDir::new().unwrap();
1463 let dest = dest_holder.path().join("out");
1464 fs::create_dir(&dest).unwrap();
1465 fs::write(dest.join("existing.txt"), b"user data\n").unwrap();
1466
1467 let err = repo
1468 .materialize_thread("main", &dest, &AudienceTier::Internal)
1469 .unwrap_err();
1470
1471 assert!(err.to_string().contains("is not empty"), "{err}");
1472 assert_eq!(
1473 fs::read_to_string(dest.join("existing.txt")).unwrap(),
1474 "user data\n"
1475 );
1476 assert!(!dest.join("file.txt").exists());
1477 }
1478
1479 #[cfg(unix)]
1480 #[test]
1481 fn materialize_thread_rejects_symlink_target() {
1482 let (_repo_dir, repo) = seeded_repo();
1483 let dest_holder = TempDir::new().unwrap();
1484 let real = dest_holder.path().join("real");
1485 fs::create_dir(&real).unwrap();
1486 let dest = dest_holder.path().join("link");
1487 std::os::unix::fs::symlink(&real, &dest).unwrap();
1488
1489 let err = repo
1490 .materialize_thread("main", &dest, &AudienceTier::Internal)
1491 .unwrap_err();
1492
1493 assert!(err.to_string().contains("cannot be a symlink"), "{err}");
1494 assert!(!real.join("file.txt").exists());
1495 }
1496
1497 #[test]
1498 fn materialize_thread_rejects_file_target() {
1499 let (_repo_dir, repo) = seeded_repo();
1500 let dest_holder = TempDir::new().unwrap();
1501 let dest = dest_holder.path().join("file");
1502 fs::write(&dest, b"user data\n").unwrap();
1503
1504 let err = repo
1505 .materialize_thread("main", &dest, &AudienceTier::Internal)
1506 .unwrap_err();
1507
1508 assert!(err.to_string().contains("must be a directory"), "{err}");
1509 assert_eq!(fs::read_to_string(&dest).unwrap(), "user data\n");
1510 }
1511
1512 fn embargo_state_with_tier(repo: &Repository, tier: VisibilityTier) -> StateId {
1513 use chrono::Utc;
1514 use objects::object::{Principal, StateVisibility};
1515 let state_id = repo
1516 .refs()
1517 .get_thread(&ThreadName::new("main"))
1518 .unwrap()
1519 .expect("head present");
1520 repo.put_state_visibility(StateVisibility {
1521 state: state_id,
1522 tier,
1523 embargo_until: None,
1524 declarer: Principal {
1525 name: "Grace Hopper".into(),
1526 email: "grace@example.com".into(),
1527 },
1528 declared_at: Utc::now(),
1529 signature: None,
1530 supersedes: None,
1531 })
1532 .expect("put visibility");
1533 state_id
1534 }
1535
1536 fn checkout_main(
1537 repo: &Repository,
1538 dest: &Path,
1539 audience: &AudienceTier,
1540 ) -> CheckoutMaterialization {
1541 let state_id = repo
1542 .refs()
1543 .resolve("main")
1544 .unwrap()
1545 .expect("main thread exists");
1546 let state = repo
1547 .store()
1548 .get_state(&state_id)
1549 .unwrap()
1550 .expect("main state exists");
1551 repo.checkout_state_gated(&state_id, &state, dest, audience)
1552 .unwrap()
1553 }
1554
1555 #[test]
1556 fn checkout_renders_courtesy_stub_when_state_is_under_tier_for_audience() {
1557 let repo_dir = TempDir::new().unwrap();
1558 let repo = Repository::init_default(repo_dir.path()).unwrap();
1559 fs::write(repo_dir.path().join("secret.rs"), b"fn exploit() {}\n").unwrap();
1560 repo.snapshot(Some("embargoed fix".into()), None).unwrap();
1561 embargo_state_with_tier(
1562 &repo,
1563 VisibilityTier::Private {
1564 scope_label: "sec-embargo".into(),
1565 },
1566 );
1567
1568 let dest_holder = TempDir::new().unwrap();
1569 let dest = dest_holder.path().join("out");
1570 // A Private state is withheld even from the all-seeing Internal
1571 // operator — the placeholder appears, the tracked bytes do not.
1572 let manifest = repo
1573 .materialize_thread("main", &dest, &AudienceTier::Internal)
1574 .unwrap();
1575
1576 assert!(
1577 dest.join(COURTESY_STUB_FILENAME).exists(),
1578 "courtesy placeholder must be written for an under-tier checkout"
1579 );
1580 assert!(
1581 !dest.join("secret.rs").exists(),
1582 "the tracked content must NOT be materialized for an under-tier audience"
1583 );
1584 assert!(
1585 manifest.files.is_empty(),
1586 "manifest must record no tracked files for a stubbed checkout"
1587 );
1588 let stub = fs::read_to_string(dest.join(COURTESY_STUB_FILENAME)).unwrap();
1589 assert!(stub.contains("private"));
1590 assert!(stub.contains("sec-embargo"));
1591 }
1592
1593 #[test]
1594 fn checkout_materializes_real_content_for_the_authorized_audience() {
1595 let repo_dir = TempDir::new().unwrap();
1596 let repo = Repository::init_default(repo_dir.path()).unwrap();
1597 fs::write(repo_dir.path().join("secret.rs"), b"fn exploit() {}\n").unwrap();
1598 repo.snapshot(Some("embargoed fix".into()), None).unwrap();
1599 embargo_state_with_tier(
1600 &repo,
1601 VisibilityTier::Private {
1602 scope_label: "sec-embargo".into(),
1603 },
1604 );
1605
1606 let dest_holder = TempDir::new().unwrap();
1607 let dest = dest_holder.path().join("out");
1608 // The holder of the matching restricted scope sees the real bytes.
1609 let manifest = repo
1610 .materialize_thread(
1611 "main",
1612 &dest,
1613 &AudienceTier::Restricted("sec-embargo".into()),
1614 )
1615 .unwrap();
1616
1617 assert!(dest.join("secret.rs").exists());
1618 assert!(!dest.join(COURTESY_STUB_FILENAME).exists());
1619 assert!(manifest.files.contains_key("secret.rs"));
1620 }
1621
1622 /// #316 / PR #528 r6: a worktree root first materialized under-tier (stub
1623 /// written) and later re-materialized for an authorized audience must end up
1624 /// with a clean tree — the real bytes present AND the stale courtesy stub
1625 /// removed. `materialize_tree` only writes tracked leaves, so without an
1626 /// explicit removal the stub would linger on disk after the visible path.
1627 #[test]
1628 fn authorized_rematerialize_removes_stale_embargo_stub() {
1629 let repo_dir = TempDir::new().unwrap();
1630 let repo = Repository::init_default(repo_dir.path()).unwrap();
1631 fs::write(repo_dir.path().join("secret.rs"), b"fn exploit() {}\n").unwrap();
1632 repo.snapshot(Some("embargoed fix".into()), None).unwrap();
1633 embargo_state_with_tier(
1634 &repo,
1635 VisibilityTier::Private {
1636 scope_label: "sec-embargo".into(),
1637 },
1638 );
1639
1640 let dest_holder = TempDir::new().unwrap();
1641 let dest = dest_holder.path().join("out");
1642
1643 // First: under-tier materialize of the root → only the stub lands.
1644 checkout_main(&repo, &dest, &AudienceTier::Internal);
1645 assert!(
1646 dest.join(COURTESY_STUB_FILENAME).exists(),
1647 "under-tier materialize must write the stub"
1648 );
1649 assert!(!dest.join("secret.rs").exists());
1650
1651 // Then: re-materialize the SAME root for an authorized audience.
1652 checkout_main(
1653 &repo,
1654 &dest,
1655 &AudienceTier::Restricted("sec-embargo".into()),
1656 );
1657
1658 assert!(
1659 dest.join("secret.rs").exists(),
1660 "authorized re-materialize must write the real tree"
1661 );
1662 assert!(
1663 !dest.join(COURTESY_STUB_FILENAME).exists(),
1664 "the stale courtesy stub must be removed on the authorized re-materialize"
1665 );
1666 }
1667
1668 /// #316 / PR #528 r7 CLASS 1 (the leak): a root first materialized for an
1669 /// AUTHORIZED audience (real tree on disk) and then re-materialized
1670 /// UNDER-TIER must end up holding ONLY the courtesy stub — none of the prior
1671 /// visible tree's tracked bytes may remain next to the stub, or the checkout
1672 /// still contains exactly the content the gate is supposed to withhold. The
1673 /// reconcile step removes the prior tracked leaves (including nested ones)
1674 /// and prunes the directories they leave empty.
1675 #[test]
1676 fn visible_then_withheld_root_has_only_stub() {
1677 let repo_dir = TempDir::new().unwrap();
1678 let repo = Repository::init_default(repo_dir.path()).unwrap();
1679 fs::write(repo_dir.path().join("secret.rs"), b"fn exploit() {}\n").unwrap();
1680 fs::create_dir_all(repo_dir.path().join("nested")).unwrap();
1681 fs::write(repo_dir.path().join("nested/inner.rs"), b"fn inner() {}\n").unwrap();
1682 repo.snapshot(Some("embargoed fix".into()), None).unwrap();
1683 embargo_state_with_tier(
1684 &repo,
1685 VisibilityTier::Private {
1686 scope_label: "sec-embargo".into(),
1687 },
1688 );
1689
1690 let dest_holder = TempDir::new().unwrap();
1691 let dest = dest_holder.path().join("out");
1692
1693 // Visible materialize: the real tree lands — the very bytes a later
1694 // under-tier materialize must withhold.
1695 checkout_main(
1696 &repo,
1697 &dest,
1698 &AudienceTier::Restricted("sec-embargo".into()),
1699 );
1700 assert!(dest.join("secret.rs").exists());
1701 assert!(dest.join("nested/inner.rs").exists());
1702
1703 // Under-tier re-materialize of the SAME root — the leak case.
1704 checkout_main(&repo, &dest, &AudienceTier::Internal);
1705
1706 assert!(
1707 dest.join(COURTESY_STUB_FILENAME).exists(),
1708 "withheld checkout must hold the courtesy stub"
1709 );
1710 assert!(
1711 !dest.join("secret.rs").exists(),
1712 "the prior visible tree's bytes must NOT remain next to the stub"
1713 );
1714 assert!(
1715 !dest.join("nested/inner.rs").exists(),
1716 "nested tracked leaves must be removed too"
1717 );
1718 // ONLY the stub remains: every prior tracked leaf — and the now-empty
1719 // directories they lived in — are gone.
1720 let remaining: Vec<_> = fs::read_dir(&dest)
1721 .unwrap()
1722 .map(|e| e.unwrap().file_name())
1723 .collect();
1724 assert_eq!(
1725 remaining.len(),
1726 1,
1727 "withheld root must contain only the courtesy stub, got {remaining:?}"
1728 );
1729 assert_eq!(remaining[0].to_str().unwrap(), COURTESY_STUB_FILENAME);
1730 }
1731
1732 /// #316 / PR #528 r7 CLASS 1 (r6 transition, as a matrix member): a root
1733 /// first materialized UNDER-TIER (stub) and then re-materialized for an
1734 /// AUTHORIZED audience must hold the real tree and NO stale stub.
1735 #[test]
1736 fn withheld_then_visible_root_has_real_tree_no_stub() {
1737 let repo_dir = TempDir::new().unwrap();
1738 let repo = Repository::init_default(repo_dir.path()).unwrap();
1739 fs::write(repo_dir.path().join("secret.rs"), b"fn exploit() {}\n").unwrap();
1740 repo.snapshot(Some("embargoed fix".into()), None).unwrap();
1741 embargo_state_with_tier(
1742 &repo,
1743 VisibilityTier::Private {
1744 scope_label: "sec-embargo".into(),
1745 },
1746 );
1747
1748 let dest_holder = TempDir::new().unwrap();
1749 let dest = dest_holder.path().join("out");
1750
1751 checkout_main(&repo, &dest, &AudienceTier::Internal);
1752 assert!(dest.join(COURTESY_STUB_FILENAME).exists());
1753 assert!(!dest.join("secret.rs").exists());
1754
1755 checkout_main(
1756 &repo,
1757 &dest,
1758 &AudienceTier::Restricted("sec-embargo".into()),
1759 );
1760 assert!(
1761 dest.join("secret.rs").exists(),
1762 "authorized re-materialize must write the real tree"
1763 );
1764 assert!(
1765 !dest.join(COURTESY_STUB_FILENAME).exists(),
1766 "the stale courtesy stub must be removed on the authorized re-materialize"
1767 );
1768 }
1769
1770 /// #316 / PR #528 r7 CLASS 1 (visible→visible): re-materializing a root at a
1771 /// NEW visible tree must leave exactly that tree — a leaf dropped from the
1772 /// new tree must not linger from the prior materialize. `materialize_tree`
1773 /// writes the new leaves but does not remove a now-absent prior leaf; the
1774 /// reconcile step closes that gap.
1775 #[test]
1776 fn visible_then_visible_refreshes_tree() {
1777 let repo_dir = TempDir::new().unwrap();
1778 let repo = Repository::init_default(repo_dir.path()).unwrap();
1779 fs::write(repo_dir.path().join("keep.rs"), b"keep\n").unwrap();
1780 fs::write(repo_dir.path().join("stale.rs"), b"stale\n").unwrap();
1781 repo.snapshot(Some("seed".into()), None).unwrap();
1782
1783 let dest_holder = TempDir::new().unwrap();
1784 let dest = dest_holder.path().join("out");
1785 checkout_main(&repo, &dest, &AudienceTier::Internal);
1786 assert!(dest.join("keep.rs").exists());
1787 assert!(dest.join("stale.rs").exists());
1788
1789 // Advance the thread head in the MAIN repo (snapshot walks repo.root,
1790 // not `dest`, so the dest manifest's worktree_path stays = dest and is
1791 // NOT refreshed here): drop stale.rs, add fresh.rs.
1792 fs::remove_file(repo_dir.path().join("stale.rs")).unwrap();
1793 fs::write(repo_dir.path().join("fresh.rs"), b"fresh\n").unwrap();
1794 repo.snapshot(Some("advance".into()), None).unwrap();
1795
1796 // Re-materialize the SAME root at the new (still visible) head.
1797 checkout_main(&repo, &dest, &AudienceTier::Internal);
1798 assert!(dest.join("keep.rs").exists(), "an unchanged leaf stays");
1799 assert!(dest.join("fresh.rs").exists(), "the new leaf is written");
1800 assert!(
1801 !dest.join("stale.rs").exists(),
1802 "a leaf dropped from the new tree must not linger from the prior materialize"
1803 );
1804 assert!(
1805 !dest.join(COURTESY_STUB_FILENAME).exists(),
1806 "a visible re-materialize writes no stub"
1807 );
1808 }
1809
1810 /// #316 / PR #528 r7 CLASS 1 (withheld→withheld): two under-tier
1811 /// materializes of the same root leave only the stub each time, and capture
1812 /// stays a no-op.
1813 #[test]
1814 fn withheld_then_withheld_stays_withheld() {
1815 let repo_dir = TempDir::new().unwrap();
1816 let repo = Repository::init_default(repo_dir.path()).unwrap();
1817 fs::write(repo_dir.path().join("secret.rs"), b"fn exploit() {}\n").unwrap();
1818 repo.snapshot(Some("embargoed fix".into()), None).unwrap();
1819 embargo_state_with_tier(
1820 &repo,
1821 VisibilityTier::Private {
1822 scope_label: "sec-embargo".into(),
1823 },
1824 );
1825
1826 let dest_holder = TempDir::new().unwrap();
1827 let dest = dest_holder.path().join("out");
1828
1829 checkout_main(&repo, &dest, &AudienceTier::Internal);
1830 assert!(dest.join(COURTESY_STUB_FILENAME).exists());
1831 assert!(!dest.join("secret.rs").exists());
1832
1833 // Second under-tier checkout of the same root: still only the stub.
1834 checkout_main(&repo, &dest, &AudienceTier::Internal);
1835 let remaining: Vec<_> = fs::read_dir(&dest)
1836 .unwrap()
1837 .map(|e| e.unwrap().file_name())
1838 .collect();
1839 assert_eq!(
1840 remaining.len(),
1841 1,
1842 "withheld root must contain only the courtesy stub, got {remaining:?}"
1843 );
1844 assert_eq!(remaining[0].to_str().unwrap(), COURTESY_STUB_FILENAME);
1845 assert!(!dest.join("secret.rs").exists());
1846
1847 // Capture of the still-withheld root is a no-op.
1848 let outcome = repo.capture_thread_from_disk("main", &dest).unwrap();
1849 assert_eq!(outcome, ThreadCaptureOutcome::NoOp);
1850 }
1851
1852 /// #316 / PR #528 r9 FINDING A: the withheld marker (and `.leaves` record)
1853 /// must be keyed on the root `capture_thread_from_disk` resolves at
1854 /// READ-time, not on a pre-materialization path. `canonical_worktree_path`
1855 /// falls back to its raw input when the path does not yet resolve, so a dest
1856 /// reached THROUGH a symlink whose leaf does not exist yet canonicalizes to
1857 /// the un-resolved `link/out` before the dir is made but to the resolved
1858 /// `real/out` after. Pre-fix the marker was written under `link/out` while
1859 /// capture looked it up under `real/out` → marker missed → a withheld
1860 /// checkout captured as a stub-only tree instead of no-oping.
1861 #[cfg(unix)]
1862 #[test]
1863 fn withheld_marker_keyed_on_canonical_root_for_relative_dest() {
1864 let repo_dir = TempDir::new().unwrap();
1865 let repo = Repository::init_default(repo_dir.path()).unwrap();
1866 fs::write(repo_dir.path().join("secret.rs"), b"fn exploit() {}\n").unwrap();
1867 repo.snapshot(Some("embargoed fix".into()), None).unwrap();
1868 embargo_state_with_tier(
1869 &repo,
1870 VisibilityTier::Private {
1871 scope_label: "sec-embargo".into(),
1872 },
1873 );
1874
1875 // `dest` travels through a symlink to a not-yet-existing leaf, so a
1876 // canonicalize BEFORE the dir is created resolves differently (falls
1877 // back to `link/out`) than one AFTER (`real/out`).
1878 let dest_holder = TempDir::new().unwrap();
1879 let real = dest_holder.path().join("real");
1880 fs::create_dir_all(&real).unwrap();
1881 std::os::unix::fs::symlink(&real, dest_holder.path().join("link")).unwrap();
1882 let dest = dest_holder.path().join("link").join("out");
1883
1884 repo.materialize_thread("main", &dest, &AudienceTier::Internal)
1885 .unwrap();
1886 assert!(dest.join(COURTESY_STUB_FILENAME).exists());
1887 assert!(!dest.join("secret.rs").exists());
1888
1889 // Capture through the symlinked path must be a NO-OP: the marker was
1890 // keyed on the same canonical root (`real/out`) capture resolves.
1891 let outcome = repo.capture_thread_from_disk("main", &dest).unwrap();
1892 assert_eq!(
1893 outcome,
1894 ThreadCaptureOutcome::NoOp,
1895 "withheld checkout reached via a symlinked path must not be capturable"
1896 );
1897 }
1898
1899 /// #316 / PR #528 r8 HOLE 1: the withheld reduction must NOT depend on the
1900 /// clobberable per-thread `manifest.toml`. A root first materialized VISIBLE
1901 /// (holding `old-secret.txt`), THEN observed while a sibling worktree of the
1902 /// SAME thread is materialized (the event that clobbers the per-thread
1903 /// manifest, retargeting it at the sibling's root), THEN re-materialized
1904 /// WITHHELD against a LATER state whose tree no longer contains
1905 /// `old-secret.txt`, must still end up holding ONLY the courtesy stub. The
1906 /// secret is in NEITHER the withheld state's own tree NOR (post-clobber) the
1907 /// per-thread manifest — only the clobber-proof per-root record names it, so
1908 /// the reduction can only succeed by sourcing that record.
1909 #[test]
1910 fn withheld_reduction_survives_sibling_manifest_clobber() {
1911 let repo_dir = TempDir::new().unwrap();
1912 let repo = Repository::init_default(repo_dir.path()).unwrap();
1913
1914 // State S1 (visible): contains the secret that must not linger later.
1915 fs::write(repo_dir.path().join("old-secret.txt"), b"launch codes\n").unwrap();
1916 repo.snapshot(Some("seed with secret".into()), None)
1917 .unwrap();
1918
1919 // Root A materialized VISIBLE at S1 — the real bytes land on disk and the
1920 // clobber-proof per-root record for A captures `old-secret.txt`.
1921 let a_holder = TempDir::new().unwrap();
1922 let root_a = a_holder.path().join("root-a");
1923 checkout_main(&repo, &root_a, &AudienceTier::Internal);
1924 assert!(root_a.join("old-secret.txt").exists());
1925
1926 // Advance the thread to S2: the secret is REMOVED before this state, a
1927 // new tracked file replaces it. So `old-secret.txt` is absent from S2's
1928 // tree entirely.
1929 fs::remove_file(repo_dir.path().join("old-secret.txt")).unwrap();
1930 fs::write(repo_dir.path().join("kept.txt"), b"benign\n").unwrap();
1931 repo.snapshot(Some("drop secret, advance".into()), None)
1932 .unwrap();
1933 embargo_state_with_tier(
1934 &repo,
1935 VisibilityTier::Private {
1936 scope_label: "sec-embargo".into(),
1937 },
1938 );
1939
1940 // A sibling worktree B of the SAME thread is materialized (authorized, at
1941 // S2). `materialize_thread` rewrites `threads/main/manifest.toml` keyed by
1942 // thread name, so this CLOBBERS A's record there — `manifest_for_worktree_root(A)`
1943 // now resolves to B, the precise race that reopened the leak in r7.
1944 let b_holder = TempDir::new().unwrap();
1945 let root_b = b_holder.path().join("root-b");
1946 repo.materialize_thread(
1947 "main",
1948 &root_b,
1949 &AudienceTier::Restricted("sec-embargo".into()),
1950 )
1951 .unwrap();
1952 assert!(root_b.join("kept.txt").exists());
1953 // Confirm the clobber really happened: the per-thread manifest no longer
1954 // records root A.
1955 assert!(
1956 crate::thread_manifest::manifest_for_worktree_root(
1957 repo.heddle_dir(),
1958 &canonical_worktree_path(&root_a),
1959 )
1960 .unwrap()
1961 .is_none(),
1962 "sibling materialize must have clobbered A's per-thread manifest record"
1963 );
1964
1965 // Re-materialize root A WITHHELD (Internal can't see S2's Private tier).
1966 // S2's tree does not contain `old-secret.txt`, and the per-thread
1967 // manifest no longer names A — only the clobber-proof per-root record can
1968 // drive its removal.
1969 checkout_main(&repo, &root_a, &AudienceTier::Internal);
1970
1971 assert!(
1972 root_a.join(COURTESY_STUB_FILENAME).exists(),
1973 "withheld checkout must hold the courtesy stub"
1974 );
1975 assert!(
1976 !root_a.join("old-secret.txt").exists(),
1977 "the prior visible tree's secret must be GONE even though the per-thread manifest was clobbered"
1978 );
1979 let remaining: Vec<_> = fs::read_dir(&root_a)
1980 .unwrap()
1981 .map(|e| e.unwrap().file_name())
1982 .collect();
1983 assert_eq!(
1984 remaining.len(),
1985 1,
1986 "withheld root must contain only the courtesy stub, got {remaining:?}"
1987 );
1988 assert_eq!(remaining[0].to_str().unwrap(), COURTESY_STUB_FILENAME);
1989 }
1990
1991 /// #316 / PR #528 r9 FINDING 4: close the per-root `.leaves`-staleness CLASS.
1992 /// `capture_thread_from_disk` rewrites `manifest.toml` but used to leave the
1993 /// clobber-proof per-root `.leaves` record untouched, so a captured-but-
1994 /// later-withheld leaf leaked. Sequence: a visible checkout holding `{a}`;
1995 /// the user adds `b` and captures (head advances, `.leaves` MUST refresh to
1996 /// `{a, b}`); the thread then advances to a state whose tree drops `b` and is
1997 /// embargoed; re-materializing the SAME root WITHHELD against that state must
1998 /// leave ONLY the stub — `b` (on disk from the capture) must be GONE, not
1999 /// leaked next to the stub. The withheld state's own tree lacks `b`, so only
2000 /// a `.leaves` record the capture refreshed can drive `b`'s removal.
2001 #[test]
2002 fn capture_refreshes_materialized_leaves() {
2003 let repo_dir = TempDir::new().unwrap();
2004 let repo = Repository::init_default(repo_dir.path()).unwrap();
2005
2006 // S1 (visible): tracked `a.txt`.
2007 fs::write(repo_dir.path().join("a.txt"), b"alpha\n").unwrap();
2008 repo.snapshot(Some("seed a".into()), None).unwrap();
2009
2010 // Materialize root R visible (Internal) at S1 → disk {a.txt},
2011 // .leaves(R) = {a.txt}.
2012 let holder = TempDir::new().unwrap();
2013 let root = holder.path().join("root");
2014 checkout_main(&repo, &root, &AudienceTier::Internal);
2015 assert!(root.join("a.txt").exists());
2016
2017 // User adds `b.txt` in R and captures → head advances to S2 = {a, b}.
2018 // The capture MUST refresh the per-root `.leaves` record to include
2019 // `b.txt` (the class-fix: capture rewrites the manifest AND `.leaves`).
2020 fs::write(root.join("b.txt"), b"beta\n").unwrap();
2021 match repo.capture_thread_from_disk("main", &root).unwrap() {
2022 ThreadCaptureOutcome::Captured { .. } => {}
2023 ThreadCaptureOutcome::NoOp => panic!("adding b.txt must produce a real capture"),
2024 }
2025 let leaves = crate::thread_manifest::read_materialized_leaves(
2026 repo.heddle_dir(),
2027 &canonical_worktree_path(&root),
2028 )
2029 .unwrap()
2030 .expect("capture must have written a per-root leaves record");
2031 assert!(
2032 leaves.contains("a.txt") && leaves.contains("b.txt"),
2033 "capture must refresh the per-root .leaves record to the captured tree's leaves, got {leaves:?}"
2034 );
2035
2036 // Advance the thread to S3 whose tree LACKS b.txt: snapshot from the main
2037 // repo dir (which only holds a.txt and is NOT the materialized worktree,
2038 // so the manifest is not refreshed here), then embargo S3 Private.
2039 fs::write(repo_dir.path().join("a.txt"), b"alpha v2\n").unwrap();
2040 repo.snapshot(Some("drop b, advance".into()), None).unwrap();
2041 embargo_state_with_tier(
2042 &repo,
2043 VisibilityTier::Private {
2044 scope_label: "sec-embargo".into(),
2045 },
2046 );
2047
2048 // Re-materialize R WITHHELD (Internal under-tier for the Private S3). S3's
2049 // own tree has no b.txt, so the withheld reduction can only remove the
2050 // capture-added b.txt by sourcing the refreshed per-root record.
2051 checkout_main(&repo, &root, &AudienceTier::Internal);
2052
2053 assert!(
2054 root.join(COURTESY_STUB_FILENAME).exists(),
2055 "withheld checkout must hold the courtesy stub"
2056 );
2057 assert!(
2058 !root.join("b.txt").exists(),
2059 "the capture-added leaf must be removed by the withheld reduction, not leaked next to the stub"
2060 );
2061 let remaining: Vec<_> = fs::read_dir(&root)
2062 .unwrap()
2063 .map(|e| e.unwrap().file_name())
2064 .collect();
2065 assert_eq!(
2066 remaining.len(),
2067 1,
2068 "withheld root must contain only the courtesy stub, got {remaining:?}"
2069 );
2070 assert_eq!(remaining[0].to_str().unwrap(), COURTESY_STUB_FILENAME);
2071 }
2072
2073 /// #316 / PR #528 r3 Finding 1: materializing an under-tier checkout writes
2074 /// the courtesy stub and marks the manifest `withheld`. A subsequent
2075 /// capture of that checkout must be a NO-OP — it must NOT pull the stub in
2076 /// as tracked content, and (crucially) must NOT commit an empty tree that
2077 /// wipes the withheld state's real files. The thread head stays put.
2078 #[test]
2079 fn capture_skips_embargo_courtesy_stub() {
2080 let repo_dir = TempDir::new().unwrap();
2081 let repo = Repository::init_default(repo_dir.path()).unwrap();
2082 fs::write(repo_dir.path().join("secret.rs"), b"fn exploit() {}\n").unwrap();
2083 repo.snapshot(Some("embargoed fix".into()), None).unwrap();
2084 embargo_state_with_tier(
2085 &repo,
2086 VisibilityTier::Private {
2087 scope_label: "sec-embargo".into(),
2088 },
2089 );
2090
2091 let dest_holder = TempDir::new().unwrap();
2092 let dest = dest_holder.path().join("out");
2093 // Under-tier audience → only the stub lands; no real bytes, empty files.
2094 let manifest = repo
2095 .materialize_thread("main", &dest, &AudienceTier::Internal)
2096 .unwrap();
2097 assert!(
2098 dest.join(COURTESY_STUB_FILENAME).exists(),
2099 "stub must be written for the under-tier checkout"
2100 );
2101 assert!(
2102 manifest.files.is_empty(),
2103 "no tracked files in a stub checkout"
2104 );
2105 assert!(
2106 manifest.withheld,
2107 "manifest must mark the checkout withheld"
2108 );
2109
2110 let head_before = repo
2111 .refs()
2112 .get_thread(&ThreadName::new("main"))
2113 .unwrap()
2114 .expect("head");
2115
2116 // Capture the withheld checkout.
2117 let outcome = repo.capture_thread_from_disk("main", &dest).unwrap();
2118 assert_eq!(
2119 outcome,
2120 ThreadCaptureOutcome::NoOp,
2121 "a withheld checkout is non-capturable"
2122 );
2123
2124 // Thread head must not have moved.
2125 let head_after = repo
2126 .refs()
2127 .get_thread(&ThreadName::new("main"))
2128 .unwrap()
2129 .expect("head");
2130 assert_eq!(
2131 head_before, head_after,
2132 "withheld capture must not advance the thread head"
2133 );
2134
2135 // The thread's tree is still the real embargoed tree: it contains the
2136 // withheld content and NOT the courtesy stub.
2137 let head_state = repo.store().get_state(&head_after).unwrap().unwrap();
2138 let tree = repo.store().get_tree(&head_state.tree).unwrap().unwrap();
2139 assert!(
2140 !tree
2141 .entries()
2142 .iter()
2143 .any(|e| e.name() == COURTESY_STUB_FILENAME),
2144 "captured tree must never contain the courtesy stub"
2145 );
2146 assert!(
2147 tree.entries().iter().any(|e| e.name() == "secret.rs"),
2148 "the withheld real content must remain intact in the thread"
2149 );
2150 }
2151
2152 /// #316 / PR #528 r4: the withheld status must be scoped per *worktree
2153 /// root*, not per thread. When one thread is materialized into TWO
2154 /// worktrees — an authorized one A (real bytes) and an under-tier one B
2155 /// (withheld stub) — the under-tier materialize of B clobbers the single
2156 /// per-thread `manifest.toml`. A withheld flag stored there would then
2157 /// wrongly suppress a capture of A, silently dropping legitimate work.
2158 /// With the per-worktree marker, A captures its real edits and B no-ops.
2159 #[test]
2160 fn withheld_manifest_is_per_worktree_not_per_thread() {
2161 let repo_dir = TempDir::new().unwrap();
2162 let repo = Repository::init_default(repo_dir.path()).unwrap();
2163 fs::write(repo_dir.path().join("secret.rs"), b"fn exploit() {}\n").unwrap();
2164 repo.snapshot(Some("embargoed fix".into()), None).unwrap();
2165 embargo_state_with_tier(
2166 &repo,
2167 VisibilityTier::Private {
2168 scope_label: "sec-embargo".into(),
2169 },
2170 );
2171
2172 let holder_a = TempDir::new().unwrap();
2173 let worktree_a = holder_a.path().join("authorized");
2174 let holder_b = TempDir::new().unwrap();
2175 let worktree_b = holder_b.path().join("under-tier");
2176
2177 // Worktree A: the matching-scope holder gets the real bytes.
2178 let manifest_a = repo
2179 .materialize_thread(
2180 "main",
2181 &worktree_a,
2182 &AudienceTier::Restricted("sec-embargo".into()),
2183 )
2184 .unwrap();
2185 assert!(worktree_a.join("secret.rs").exists());
2186 assert!(manifest_a.files.contains_key("secret.rs"));
2187
2188 // Edit A so a correct capture produces a NEW state. Without the edit,
2189 // capturing unchanged real content is a *legitimate* no-op and wouldn't
2190 // distinguish the bug (wrong withheld-suppression) from correct
2191 // behaviour.
2192 fs::write(worktree_a.join("extra.rs"), b"fn added() {}\n").unwrap();
2193
2194 let head_before = repo
2195 .refs()
2196 .get_thread(&ThreadName::new("main"))
2197 .unwrap()
2198 .expect("head");
2199
2200 // Worktree B: under-tier audience → stub only, withheld. This clobbers
2201 // the single per-thread `manifest.toml` with B's withheld record.
2202 let manifest_b = repo
2203 .materialize_thread("main", &worktree_b, &AudienceTier::Internal)
2204 .unwrap();
2205 assert!(worktree_b.join(COURTESY_STUB_FILENAME).exists());
2206 assert!(manifest_b.files.is_empty());
2207
2208 // Capture A: must capture the real edit — its withheld status is its
2209 // own (none), NOT inherited from B's clobbering materialize.
2210 let outcome_a = repo.capture_thread_from_disk("main", &worktree_a).unwrap();
2211 let captured_state = match outcome_a {
2212 ThreadCaptureOutcome::Captured { state_id } => state_id,
2213 ThreadCaptureOutcome::NoOp => {
2214 panic!("authorized worktree A must capture its real edit, not be suppressed")
2215 }
2216 };
2217 let head_after_a = repo
2218 .refs()
2219 .get_thread(&ThreadName::new("main"))
2220 .unwrap()
2221 .expect("head");
2222 assert_ne!(head_before, head_after_a, "capture A must advance the head");
2223 assert_eq!(head_after_a, captured_state);
2224 // The captured tree carries the edit and the real content, never the stub.
2225 let captured_tree = repo
2226 .store()
2227 .get_tree(
2228 &repo
2229 .store()
2230 .get_state(&captured_state)
2231 .unwrap()
2232 .unwrap()
2233 .tree,
2234 )
2235 .unwrap()
2236 .unwrap();
2237 assert!(
2238 captured_tree
2239 .entries()
2240 .iter()
2241 .any(|e| e.name() == "extra.rs")
2242 );
2243 assert!(
2244 captured_tree
2245 .entries()
2246 .iter()
2247 .any(|e| e.name() == "secret.rs")
2248 );
2249 assert!(
2250 !captured_tree
2251 .entries()
2252 .iter()
2253 .any(|e| e.name() == COURTESY_STUB_FILENAME)
2254 );
2255
2256 // Capture B: must be a no-op — its own worktree is withheld.
2257 let outcome_b = repo.capture_thread_from_disk("main", &worktree_b).unwrap();
2258 assert_eq!(
2259 outcome_b,
2260 ThreadCaptureOutcome::NoOp,
2261 "under-tier worktree B is non-capturable"
2262 );
2263 let head_after_b = repo
2264 .refs()
2265 .get_thread(&ThreadName::new("main"))
2266 .unwrap()
2267 .expect("head");
2268 assert_eq!(
2269 head_after_a, head_after_b,
2270 "withheld capture of B must not advance the head"
2271 );
2272 }
2273
2274 /// `record_thread_manifest` should write a manifest sidecar that
2275 /// matches what `materialize_thread` would have produced, for a
2276 /// worktree the caller materialized directly via `materialize_tree`.
2277 /// Used by the CLI's `start` path (which sets the worktree up
2278 /// itself rather than going through `materialize_thread`).
2279 #[test]
2280 fn record_thread_manifest_writes_sidecar_for_externally_materialized_worktree() {
2281 let repo_dir = TempDir::new().unwrap();
2282 let repo = Repository::init_default(repo_dir.path()).unwrap();
2283 fs::write(repo_dir.path().join("a.txt"), b"alpha\n").unwrap();
2284 fs::write(repo_dir.path().join("b.txt"), b"beta\n").unwrap();
2285 repo.snapshot(Some("seed".into()), None).unwrap();
2286 let state_id = repo
2287 .refs()
2288 .get_thread(&ThreadName::new("main"))
2289 .unwrap()
2290 .expect("head present");
2291
2292 // Materialize externally via the lower-level `materialize_tree`
2293 // path — the shape `start --workspace materialized` uses.
2294 let dest_holder = TempDir::new().unwrap();
2295 let dest = dest_holder.path().join("out");
2296 let state = repo.store().get_state(&state_id).unwrap().unwrap();
2297 let tree = repo.store().get_tree(&state.tree).unwrap().unwrap();
2298 repo.materialize_tree(&tree, &dest).unwrap();
2299
2300 // No manifest written yet — `materialize_tree` is the bytes-only
2301 // step; the sidecar is recorded explicitly.
2302 assert!(
2303 read_manifest(repo.heddle_dir(), "feature/x")
2304 .unwrap()
2305 .is_none()
2306 );
2307
2308 let recorded = repo
2309 .record_thread_manifest("feature/x", &state_id, &dest)
2310 .unwrap();
2311 assert_eq!(recorded.state_id, state_id);
2312 assert_eq!(recorded.tree_hash, state.tree);
2313 assert!(recorded.files.contains_key("a.txt"));
2314 assert!(recorded.files.contains_key("b.txt"));
2315 assert_eq!(recorded.files["a.txt"].size, b"alpha\n".len() as u64);
2316
2317 // Sidecar persists at the expected location and round-trips.
2318 let loaded = read_manifest(repo.heddle_dir(), "feature/x")
2319 .unwrap()
2320 .expect("manifest on disk");
2321 assert_eq!(loaded.state_id, recorded.state_id);
2322 assert_eq!(loaded.files.len(), recorded.files.len());
2323
2324 // Idempotent: a second recording for the same thread succeeds
2325 // (used by `capture_thread_from_disk` post-capture refresh).
2326 repo.record_thread_manifest("feature/x", &state_id, &dest)
2327 .unwrap();
2328 }
2329
2330 /// `record_thread_manifest` against an unknown `state_id` should
2331 /// surface a clear "state missing" error instead of silently
2332 /// writing a manifest with no files (which would later look like
2333 /// a deletion of every tracked path).
2334 #[test]
2335 fn record_thread_manifest_errors_when_state_is_missing() {
2336 let repo_dir = TempDir::new().unwrap();
2337 let repo = Repository::init_default(repo_dir.path()).unwrap();
2338 let dest = TempDir::new().unwrap();
2339 let missing = crate::test_state_id();
2340 let err = repo
2341 .record_thread_manifest("feature/x", &missing, &dest.path().join("out"))
2342 .expect_err("should fail when state is unknown");
2343 let message = format!("{err}");
2344 assert!(
2345 message.contains("missing"),
2346 "error message names the missing artifact: {message}"
2347 );
2348 }
2349
2350 #[test]
2351 fn materialize_unknown_thread_errors() {
2352 let repo_dir = TempDir::new().unwrap();
2353 let repo = Repository::init_default(repo_dir.path()).unwrap();
2354 let dest = TempDir::new().unwrap();
2355 let err = repo
2356 .materialize_thread(
2357 "no-such-thread",
2358 &dest.path().join("out"),
2359 &AudienceTier::Internal,
2360 )
2361 .expect_err("should fail");
2362 assert!(format!("{err}").contains("unknown thread"));
2363 }
2364
2365 /// Round-trip: materialize → edit a file → capture → confirm a
2366 /// new state was written, thread head advanced, and the manifest
2367 /// reflects the new state.
2368 #[test]
2369 fn capture_after_edit_advances_thread() {
2370 let repo_dir = TempDir::new().unwrap();
2371 let repo = Repository::init_default(repo_dir.path()).unwrap();
2372 fs::write(repo_dir.path().join("hello.txt"), b"hello\n").unwrap();
2373 repo.snapshot(Some("seed".into()), None).unwrap();
2374 let before = repo
2375 .refs()
2376 .get_thread(&ThreadName::new("main"))
2377 .unwrap()
2378 .expect("head");
2379
2380 let dest_holder = TempDir::new().unwrap();
2381 let dest = dest_holder.path().join("out");
2382 let materialize_manifest = repo
2383 .materialize_thread("main", &dest, &AudienceTier::Internal)
2384 .unwrap();
2385
2386 // Mutate a file in the materialized worktree.
2387 fs::write(dest.join("hello.txt"), b"hello world\n").unwrap();
2388
2389 let outcome = repo
2390 .capture_thread_from_disk("main", &dest)
2391 .expect("capture");
2392 let new_state = match outcome {
2393 ThreadCaptureOutcome::Captured { state_id } => state_id,
2394 ThreadCaptureOutcome::NoOp => panic!("expected Captured, got NoOp"),
2395 };
2396
2397 // Thread head advanced.
2398 let after = repo
2399 .refs()
2400 .get_thread(&ThreadName::new("main"))
2401 .unwrap()
2402 .expect("head");
2403 assert_ne!(before, after);
2404 assert_eq!(after, new_state);
2405
2406 // Manifest reflects the new state.
2407 let loaded = read_manifest(repo.heddle_dir(), "main")
2408 .unwrap()
2409 .expect("manifest");
2410 assert_eq!(loaded.state_id, new_state);
2411 assert_ne!(loaded.tree_hash, materialize_manifest.tree_hash);
2412 assert!(loaded.files.contains_key("hello.txt"));
2413 }
2414
2415 /// Capture with no edits is a no-op: thread head unchanged,
2416 /// manifest refreshed in place.
2417 #[test]
2418 fn capture_with_no_changes_is_noop() {
2419 let repo_dir = TempDir::new().unwrap();
2420 let repo = Repository::init_default(repo_dir.path()).unwrap();
2421 fs::write(repo_dir.path().join("steady.txt"), b"unchanged\n").unwrap();
2422 repo.snapshot(Some("seed".into()), None).unwrap();
2423 let before = repo
2424 .refs()
2425 .get_thread(&ThreadName::new("main"))
2426 .unwrap()
2427 .expect("head");
2428
2429 let dest_holder = TempDir::new().unwrap();
2430 let dest = dest_holder.path().join("out");
2431 repo.materialize_thread("main", &dest, &AudienceTier::Internal)
2432 .unwrap();
2433
2434 let outcome = repo.capture_thread_from_disk("main", &dest).unwrap();
2435 assert_eq!(outcome, ThreadCaptureOutcome::NoOp);
2436
2437 // Thread head unchanged.
2438 let after = repo
2439 .refs()
2440 .get_thread(&ThreadName::new("main"))
2441 .unwrap()
2442 .expect("head");
2443 assert_eq!(before, after);
2444 }
2445
2446 /// Stat-cache fast no-op: a fresh-materialised tree captures
2447 /// without invoking `build_tree`. Detected via the manifest
2448 /// reflecting bytes byte-identical to what got materialised.
2449 #[test]
2450 fn stat_cache_short_circuits_unchanged_capture() {
2451 let repo_dir = TempDir::new().unwrap();
2452 let repo = Repository::init_default(repo_dir.path()).unwrap();
2453 for i in 0..20 {
2454 fs::write(
2455 repo_dir.path().join(format!("file_{i:02}.txt")),
2456 format!("content {i}\n").as_bytes(),
2457 )
2458 .unwrap();
2459 }
2460 repo.snapshot(Some("seed".into()), None).unwrap();
2461
2462 let dest_holder = TempDir::new().unwrap();
2463 let dest = dest_holder.path().join("out");
2464 let manifest = repo
2465 .materialize_thread("main", &dest, &AudienceTier::Internal)
2466 .unwrap();
2467 assert_eq!(manifest.files.len(), 20);
2468
2469 // The fast-path predicate alone — without touching the
2470 // store-side `build_tree`. Exposes the boundary the
2471 // optimisation guards.
2472 assert!(
2473 stat_cache_no_op(&repo, &manifest, &dest).unwrap(),
2474 "fresh materialise should stat-match the manifest"
2475 );
2476
2477 // Full call also returns NoOp.
2478 let outcome = repo.capture_thread_from_disk("main", &dest).unwrap();
2479 assert_eq!(outcome, ThreadCaptureOutcome::NoOp);
2480 }
2481
2482 /// Stat-cache invalidates correctly on edit: a single touched
2483 /// file flips `stat_cache_no_op` to `false`, which forces the
2484 /// slow path to run and produces a new state.
2485 #[test]
2486 fn stat_cache_detects_edit_and_falls_through() {
2487 let repo_dir = TempDir::new().unwrap();
2488 let repo = Repository::init_default(repo_dir.path()).unwrap();
2489 fs::write(repo_dir.path().join("only.txt"), b"v1\n").unwrap();
2490 repo.snapshot(Some("seed".into()), None).unwrap();
2491
2492 let dest_holder = TempDir::new().unwrap();
2493 let dest = dest_holder.path().join("out");
2494 let manifest = repo
2495 .materialize_thread("main", &dest, &AudienceTier::Internal)
2496 .unwrap();
2497
2498 // Sleep briefly so the mtime moves; APFS gives sub-ms
2499 // resolution on modern macOS but Linux ext4 is only
2500 // 1-second granularity for ctime — make the test robust
2501 // either way.
2502 std::thread::sleep(std::time::Duration::from_millis(20));
2503 fs::write(dest.join("only.txt"), b"v2\n").unwrap();
2504
2505 assert!(
2506 !stat_cache_no_op(&repo, &manifest, &dest).unwrap(),
2507 "edited file must invalidate the fast path"
2508 );
2509
2510 // Slow path runs and creates a new state.
2511 match repo.capture_thread_from_disk("main", &dest).unwrap() {
2512 ThreadCaptureOutcome::Captured { .. } => {}
2513 other => panic!("expected Captured, got {other:?}"),
2514 }
2515 }
2516
2517 /// New file added out of band → fast path declines.
2518 #[test]
2519 fn stat_cache_detects_added_file() {
2520 let repo_dir = TempDir::new().unwrap();
2521 let repo = Repository::init_default(repo_dir.path()).unwrap();
2522 fs::write(repo_dir.path().join("a.txt"), b"a\n").unwrap();
2523 repo.snapshot(Some("seed".into()), None).unwrap();
2524
2525 let dest_holder = TempDir::new().unwrap();
2526 let dest = dest_holder.path().join("out");
2527 let manifest = repo
2528 .materialize_thread("main", &dest, &AudienceTier::Internal)
2529 .unwrap();
2530
2531 fs::write(dest.join("b.txt"), b"b\n").unwrap();
2532
2533 assert!(
2534 !stat_cache_no_op(&repo, &manifest, &dest).unwrap(),
2535 "added file must invalidate the fast path"
2536 );
2537 }
2538
2539 /// Plain `heddle capture` (via `Repository::snapshot`) detects the
2540 /// materialized-thread context — HEAD attached to a thread that has
2541 /// a manifest — and refreshes the manifest to the new state after
2542 /// the capture lands. This is the path the user hits when they edit
2543 /// inside a materialized thread worktree and run `heddle capture`
2544 /// directly (as opposed to `thread switch`, which is the auto-capture
2545 /// path covered by `capture_after_edit_advances_thread`).
2546 #[test]
2547 fn snapshot_in_materialized_thread_refreshes_manifest() {
2548 let repo_dir = TempDir::new().unwrap();
2549 let repo = Repository::init_default(repo_dir.path()).unwrap();
2550 fs::write(repo_dir.path().join("alpha.txt"), b"v1\n").unwrap();
2551 fs::write(repo_dir.path().join("beta.txt"), b"steady\n").unwrap();
2552 let initial = repo.snapshot(Some("seed".into()), None).unwrap();
2553
2554 // Stand up a manifest for `main` whose stat fields match the
2555 // worktree as it is right now. Mimics the post-materialize
2556 // state when the user is `cd`'d into the materialized
2557 // worktree (`self.root` == materialized path).
2558 let initial_tree = repo
2559 .store()
2560 .get_tree(&initial.tree)
2561 .unwrap()
2562 .expect("seed tree");
2563 let mut manifest = crate::thread_manifest::ThreadManifest::new(
2564 initial.id(),
2565 initial.tree,
2566 canonical_worktree_path(repo_dir.path()),
2567 );
2568 populate_manifest_from_tree(
2569 &repo,
2570 &initial_tree,
2571 repo_dir.path(),
2572 "",
2573 &mut manifest.files,
2574 )
2575 .unwrap();
2576 crate::thread_manifest::write_manifest(repo.heddle_dir(), "main", &manifest).unwrap();
2577
2578 // Sleep long enough that the new mtime is observably distinct
2579 // on ext4's 1-second-granularity ctime (APFS is sub-ms).
2580 std::thread::sleep(std::time::Duration::from_millis(20));
2581 fs::write(repo_dir.path().join("alpha.txt"), b"v2\n").unwrap();
2582
2583 let captured = repo.snapshot(Some("after edit".into()), None).unwrap();
2584 assert_ne!(captured.id(), initial.id());
2585 assert_ne!(captured.tree, initial.tree);
2586
2587 // Manifest got refreshed to point at the new state and tree.
2588 let refreshed = crate::thread_manifest::read_manifest(repo.heddle_dir(), "main")
2589 .unwrap()
2590 .expect("manifest persists");
2591 assert_eq!(refreshed.state_id, captured.id());
2592 assert_eq!(refreshed.tree_hash, captured.tree);
2593 // beta.txt was untouched — its stat fields (and hash) should
2594 // still appear in the refreshed manifest.
2595 assert!(refreshed.files.contains_key("alpha.txt"));
2596 assert!(refreshed.files.contains_key("beta.txt"));
2597 }
2598
2599 /// Regression: snapshot from a directory that is NOT the
2600 /// manifest's recorded worktree path must NOT refresh the
2601 /// manifest. Pre-fix, the snapshot code detected the
2602 /// "materialized-thread context" purely by `HEAD attached + a
2603 /// manifest exists for the attached thread", so a snapshot from
2604 /// the main repo dir (or any sibling worktree) would corrupt the
2605 /// manifest by writing the wrong directory's stat fields into it
2606 /// — and `heddle status` would then falsely report the
2607 /// materialized worktree as fresh because the manifest's
2608 /// `state_id` had auto-rolled forward.
2609 #[test]
2610 fn snapshot_outside_materialized_worktree_does_not_refresh_manifest() {
2611 let repo_dir = TempDir::new().unwrap();
2612 let repo = Repository::init_default(repo_dir.path()).unwrap();
2613 fs::write(repo_dir.path().join("alpha.txt"), b"v1\n").unwrap();
2614 repo.snapshot(Some("seed".into()), None).unwrap();
2615
2616 // Materialize "main" at a totally separate path. Manifest
2617 // records `dest_holder/out` as the worktree.
2618 let dest_holder = TempDir::new().unwrap();
2619 let dest = dest_holder.path().join("out");
2620 let materialize_manifest = repo
2621 .materialize_thread("main", &dest, &AudienceTier::Internal)
2622 .unwrap();
2623 let materialize_state_id = materialize_manifest.state_id;
2624 let materialize_tree_hash = materialize_manifest.tree_hash;
2625 let materialized_path = materialize_manifest.worktree_path.clone();
2626 assert_eq!(
2627 materialized_path,
2628 canonical_worktree_path(&dest),
2629 "manifest must record the canonical materialize destination"
2630 );
2631
2632 // Now run snapshot from the MAIN repo dir (`repo.root()`) —
2633 // a path that is NOT the materialized worktree. The pre-fix
2634 // bug fired here.
2635 std::thread::sleep(std::time::Duration::from_millis(20));
2636 fs::write(repo_dir.path().join("alpha.txt"), b"v2-from-main-repo\n").unwrap();
2637 let snap = repo
2638 .snapshot(Some("from main repo, not the mat worktree".into()), None)
2639 .unwrap();
2640 assert_ne!(
2641 snap.id(),
2642 materialize_state_id,
2643 "snapshot must advance main's head"
2644 );
2645
2646 // The manifest must NOT have been refreshed: state_id and
2647 // tree_hash still point at the materialize state, worktree
2648 // path still points at `dest`.
2649 let after = crate::thread_manifest::read_manifest(repo.heddle_dir(), "main")
2650 .unwrap()
2651 .expect("manifest still present");
2652 assert_eq!(
2653 after.state_id, materialize_state_id,
2654 "manifest state_id must NOT advance when snapshot is taken outside the materialized worktree"
2655 );
2656 assert_eq!(
2657 after.tree_hash, materialize_tree_hash,
2658 "manifest tree_hash must NOT advance"
2659 );
2660 assert_eq!(
2661 after.worktree_path, materialized_path,
2662 "manifest worktree_path must be unchanged"
2663 );
2664
2665 // And `heddle status`'s staleness check should now correctly
2666 // report the materialized worktree as stale (head moved,
2667 // manifest didn't).
2668 let head_now = repo
2669 .refs()
2670 .get_thread(&ThreadName::new("main"))
2671 .unwrap()
2672 .expect("head");
2673 assert_ne!(
2674 head_now, after.state_id,
2675 "post-fix invariant: main head advanced past manifest's recorded state → stale"
2676 );
2677 }
2678
2679 /// Capture from a *dedicated* thread worktree (one whose path
2680 /// differs from `repo.root()`) must validate symlinks against
2681 /// that worktree's path, not against the main repo root.
2682 /// Pre-fix the walker passed `repo.root()` as the symlink-
2683 /// escape base, so every symlink inside a dedicated thread
2684 /// path was rejected as "outside the repo" the moment the
2685 /// slow path ran — `thread switch` auto-capture broke for any
2686 /// thread that contained a symlink. Reproduces the codex P2
2687 /// from review pass 2.
2688 #[cfg(unix)]
2689 #[test]
2690 fn capture_thread_from_disk_accepts_symlinks_in_dedicated_worktree() {
2691 let repo_dir = TempDir::new().unwrap();
2692 let repo = Repository::init_default(repo_dir.path()).unwrap();
2693 // Seed with a file + a symlink pointing inside the repo.
2694 fs::write(repo_dir.path().join("target.txt"), b"target\n").unwrap();
2695 std::os::unix::fs::symlink("target.txt", repo_dir.path().join("link")).unwrap();
2696 repo.snapshot(Some("seed".into()), None).unwrap();
2697
2698 // Materialise into a dedicated worktree — path differs
2699 // from `repo.root()`, which is exactly the case that
2700 // exposes the bug.
2701 let dest_holder = TempDir::new().unwrap();
2702 let dest = dest_holder.path().join("thread-worktree");
2703 repo.materialize_thread("main", &dest, &AudienceTier::Internal)
2704 .unwrap();
2705
2706 // Edit a non-symlink file so the slow path fires (the fast
2707 // stat-cache no-op would mask the bug). Sleep so the mtime
2708 // observably moves on coarse-granularity filesystems.
2709 std::thread::sleep(std::time::Duration::from_millis(20));
2710 fs::write(dest.join("target.txt"), b"target v2\n").unwrap();
2711
2712 // Pre-fix this errored with "symlink target escapes repo"
2713 // because `validate_symlink_target` was using `repo.root()`
2714 // as the allowed base instead of the walk root.
2715 let outcome = repo
2716 .capture_thread_from_disk("main", &dest)
2717 .expect("capture must accept symlinks inside the dedicated worktree");
2718 match outcome {
2719 ThreadCaptureOutcome::Captured { .. } => {}
2720 ThreadCaptureOutcome::NoOp => panic!("expected Captured; got NoOp"),
2721 }
2722 }
2723
2724 /// Codex pass-5 P1: when the ignore set tightens between
2725 /// materialise and capture (e.g. user adds an entry to
2726 /// `.heddleignore` covering an already-tracked path), the
2727 /// no-op predicate must bail to the slow path so `build_tree`
2728 /// can produce the tree that *now* matches the matcher. Pre-
2729 /// fix the manifest-first dispatch accepted any manifest hit
2730 /// without re-running the matcher, so the predicate silently
2731 /// false-passed and `thread switch`'s auto-capture missed
2732 /// the real tree delta.
2733 #[test]
2734 fn stat_cache_detects_ignore_config_tightening() {
2735 let repo_dir = TempDir::new().unwrap();
2736 let repo = Repository::init_default(repo_dir.path()).unwrap();
2737 // Seed: two files, no .heddleignore yet.
2738 fs::write(repo_dir.path().join("keep.txt"), b"keep\n").unwrap();
2739 fs::write(repo_dir.path().join("secret.txt"), b"secret\n").unwrap();
2740 repo.snapshot(Some("seed".into()), None).unwrap();
2741
2742 let dest_holder = TempDir::new().unwrap();
2743 let dest = dest_holder.path().join("out");
2744 let manifest = repo
2745 .materialize_thread("main", &dest, &AudienceTier::Internal)
2746 .unwrap();
2747 assert!(manifest.files.contains_key("secret.txt"));
2748
2749 // Tighten the ignore set in the source repo to exclude
2750 // `secret.txt`. The materialised worktree still has it
2751 // on disk (we just put it there), but `build_tree` would
2752 // now skip it and produce a different tree hash.
2753 fs::write(repo_dir.path().join(".heddleignore"), b"secret.txt\n").unwrap();
2754
2755 assert!(
2756 !stat_cache_no_op(&repo, &manifest, &dest).unwrap(),
2757 "ignore-config tightening over a tracked path must \
2758 invalidate the fast path; pre-fix the predicate \
2759 false-passed and auto-capture silently dropped \
2760 the resulting tree delta"
2761 );
2762 }
2763
2764 /// Codex pass-3 P2: a *tree-only* empty directory — one that
2765 /// was a captured tree entry but never had any files beneath it
2766 /// — was invisible to the pass-2 fix because `expected_dirs`
2767 /// was derived from manifest file ancestors. Removing such a
2768 /// directory left every set the same size and the predicate
2769 /// false-passed, silently dropping the change. The pass-3 fix
2770 /// derives `expected_dirs` from the captured tree directly so
2771 /// empty leaf dirs are tracked.
2772 #[test]
2773 fn stat_cache_detects_removed_tree_only_empty_directory() {
2774 let repo_dir = TempDir::new().unwrap();
2775 let repo = Repository::init_default(repo_dir.path()).unwrap();
2776 // Seed with one file (so the thread isn't empty) plus an
2777 // empty directory that becomes a tree entry on its own.
2778 fs::write(repo_dir.path().join("anchor.txt"), b"anchor\n").unwrap();
2779 fs::create_dir_all(repo_dir.path().join("empty-on-purpose")).unwrap();
2780 repo.snapshot(Some("seed".into()), None).unwrap();
2781
2782 let dest_holder = TempDir::new().unwrap();
2783 let dest = dest_holder.path().join("out");
2784 let manifest = repo
2785 .materialize_thread("main", &dest, &AudienceTier::Internal)
2786 .unwrap();
2787
2788 // Sanity: the empty dir landed on disk after materialise.
2789 assert!(
2790 dest.join("empty-on-purpose").is_dir(),
2791 "materialise must emit the empty dir on disk"
2792 );
2793
2794 // Remove the empty dir. No files inside it changed
2795 // because there never were any — pure tree-only delta.
2796 fs::remove_dir(dest.join("empty-on-purpose")).unwrap();
2797
2798 assert!(
2799 !stat_cache_no_op(&repo, &manifest, &dest).unwrap(),
2800 "removing a tree-only empty directory must invalidate \
2801 the fast path; pre-fix the predicate false-passed and \
2802 auto-capture silently dropped the deletion"
2803 );
2804 }
2805
2806 /// Empty directory added by the user — manifests only record
2807 /// files, but Heddle's tree builder emits a tree entry for the
2808 /// new dir. The stat-cache no-op predicate must decline so the
2809 /// slow path picks the change up; pre-fix it false-passed and
2810 /// `thread switch`'s auto-capture silently dropped the addition.
2811 #[test]
2812 fn stat_cache_detects_added_empty_directory() {
2813 let repo_dir = TempDir::new().unwrap();
2814 let repo = Repository::init_default(repo_dir.path()).unwrap();
2815 fs::write(repo_dir.path().join("only.txt"), b"a\n").unwrap();
2816 repo.snapshot(Some("seed".into()), None).unwrap();
2817
2818 let dest_holder = TempDir::new().unwrap();
2819 let dest = dest_holder.path().join("out");
2820 let manifest = repo
2821 .materialize_thread("main", &dest, &AudienceTier::Internal)
2822 .unwrap();
2823
2824 // Add an empty directory that has no manifest entry.
2825 fs::create_dir_all(dest.join("brand-new-empty-dir")).unwrap();
2826
2827 assert!(
2828 !stat_cache_no_op(&repo, &manifest, &dest).unwrap(),
2829 "an added empty directory must invalidate the fast path"
2830 );
2831 }
2832
2833 /// Empty directory removed by the user — the manifest expects it
2834 /// (its parent path appears as an ancestor of files) but the
2835 /// walk never visits it. The dir-side check must decline. Pre-
2836 /// fix the fast path would false-pass on this case too.
2837 #[test]
2838 fn stat_cache_detects_removed_empty_directory() {
2839 let repo_dir = TempDir::new().unwrap();
2840 let repo = Repository::init_default(repo_dir.path()).unwrap();
2841 fs::create_dir_all(repo_dir.path().join("nested/deep")).unwrap();
2842 fs::write(repo_dir.path().join("nested/deep/leaf.txt"), b"leaf\n").unwrap();
2843 repo.snapshot(Some("seed".into()), None).unwrap();
2844
2845 let dest_holder = TempDir::new().unwrap();
2846 let dest = dest_holder.path().join("out");
2847 let manifest = repo
2848 .materialize_thread("main", &dest, &AudienceTier::Internal)
2849 .unwrap();
2850
2851 // Remove the leaf file AND its parent dir. The file-side
2852 // check already catches the file removal, but if we then
2853 // synthesise a fresh leaf elsewhere we'd want the dir-side
2854 // check to catch the missing parent on its own too. Use a
2855 // slightly different shape: create + remove a sibling dir
2856 // whose ancestor matches the manifest's expected set.
2857 fs::create_dir_all(dest.join("nested/sibling-empty")).unwrap();
2858
2859 assert!(
2860 !stat_cache_no_op(&repo, &manifest, &dest).unwrap(),
2861 "an added empty directory inside an existing parent must invalidate"
2862 );
2863 }
2864
2865 /// Deleted file → fast path declines.
2866 #[test]
2867 fn stat_cache_detects_deletion() {
2868 let repo_dir = TempDir::new().unwrap();
2869 let repo = Repository::init_default(repo_dir.path()).unwrap();
2870 fs::write(repo_dir.path().join("a.txt"), b"a\n").unwrap();
2871 fs::write(repo_dir.path().join("b.txt"), b"b\n").unwrap();
2872 repo.snapshot(Some("seed".into()), None).unwrap();
2873
2874 let dest_holder = TempDir::new().unwrap();
2875 let dest = dest_holder.path().join("out");
2876 let manifest = repo
2877 .materialize_thread("main", &dest, &AudienceTier::Internal)
2878 .unwrap();
2879
2880 fs::remove_file(dest.join("a.txt")).unwrap();
2881
2882 assert!(
2883 !stat_cache_no_op(&repo, &manifest, &dest).unwrap(),
2884 "deleted file must invalidate the fast path"
2885 );
2886 }
2887
2888 /// Two `capture_thread_from_disk` calls on the same thread from
2889 /// different threads must serialize through the repository write
2890 /// lock: the thread head's parent chain must include both
2891 /// captures (no lost update where one capture's parent is the
2892 /// pre-race head instead of the other capture's state).
2893 ///
2894 /// Reproduces the race Codex P1 #2 named: pre-fix, two sibling
2895 /// worktrees doing `heddle thread switch` against the same
2896 /// source thread both read the same parent in
2897 /// `refs().get_thread()`, both `put_state` with that parent,
2898 /// both `set_thread` — whichever `set_thread` won last orphaned
2899 /// the other state on disk. With the lock both captures land in
2900 /// series and the final head's parent chain links back through
2901 /// both new states.
2902 #[test]
2903 fn concurrent_captures_serialize_via_repository_lock() {
2904 use std::sync::Arc;
2905
2906 let repo_dir = TempDir::new().unwrap();
2907 let repo = Arc::new(Repository::init_default(repo_dir.path()).unwrap());
2908 fs::write(repo_dir.path().join("shared.txt"), b"seed\n").unwrap();
2909 repo.snapshot(Some("seed".into()), None).unwrap();
2910 let initial_head = repo
2911 .refs()
2912 .get_thread(&ThreadName::new("main"))
2913 .unwrap()
2914 .expect("seeded");
2915
2916 // Two sibling materialized worktrees of the same thread.
2917 let dest_a_holder = TempDir::new().unwrap();
2918 let dest_a = dest_a_holder.path().join("a");
2919 repo.materialize_thread("main", &dest_a, &AudienceTier::Internal)
2920 .unwrap();
2921 let dest_b_holder = TempDir::new().unwrap();
2922 let dest_b = dest_b_holder.path().join("b");
2923 repo.materialize_thread("main", &dest_b, &AudienceTier::Internal)
2924 .unwrap();
2925
2926 // Disjoint edits so each capture has real work to do (no
2927 // stat-cache no-op short-circuit).
2928 std::thread::sleep(std::time::Duration::from_millis(20));
2929 fs::write(dest_a.join("shared.txt"), b"edited-by-a\n").unwrap();
2930 fs::write(dest_b.join("shared.txt"), b"edited-by-b\n").unwrap();
2931
2932 // Race the two captures.
2933 let repo_a = Arc::clone(&repo);
2934 let repo_b = Arc::clone(&repo);
2935 let h_a = std::thread::spawn(move || {
2936 repo_a
2937 .capture_thread_from_disk("main", &dest_a)
2938 .expect("capture A")
2939 });
2940 let h_b = std::thread::spawn(move || {
2941 repo_b
2942 .capture_thread_from_disk("main", &dest_b)
2943 .expect("capture B")
2944 });
2945 let outcome_a = h_a.join().expect("thread A");
2946 let outcome_b = h_b.join().expect("thread B");
2947
2948 // Both captures landed (neither was a NoOp because both
2949 // edited the same file with different bytes).
2950 let id_a = match outcome_a {
2951 ThreadCaptureOutcome::Captured { state_id } => state_id,
2952 ThreadCaptureOutcome::NoOp => panic!("A expected Captured"),
2953 };
2954 let id_b = match outcome_b {
2955 ThreadCaptureOutcome::Captured { state_id } => state_id,
2956 ThreadCaptureOutcome::NoOp => panic!("B expected Captured"),
2957 };
2958 assert_ne!(id_a, id_b, "the two captures must produce distinct states");
2959
2960 // The thread head is one of the two captures. Lock-naked,
2961 // the loser's parent would be `initial_head`. With the
2962 // lock, the loser's parent is the winner's id and the
2963 // winner's parent is `initial_head`.
2964 let final_head = repo
2965 .refs()
2966 .get_thread(&ThreadName::new("main"))
2967 .unwrap()
2968 .expect("head");
2969 let winner_id = final_head;
2970 let loser_id = if final_head == id_a { id_b } else { id_a };
2971
2972 let winner_state = repo
2973 .store()
2974 .get_state(&winner_id)
2975 .unwrap()
2976 .expect("winner state on disk");
2977 let loser_state = repo
2978 .store()
2979 .get_state(&loser_id)
2980 .unwrap()
2981 .expect("loser state on disk");
2982
2983 // The two captures must have linked through the lock:
2984 // exactly one of (winner.parents, loser.parents) names the
2985 // other; the remaining parent is the seed head. Pre-fix
2986 // both states named the seed head and the loser was
2987 // orphaned — assert that this isn't the case.
2988 let chained =
2989 winner_state.parents.contains(&loser_id) || loser_state.parents.contains(&winner_id);
2990 assert!(
2991 chained,
2992 "concurrent captures must chain through the lock; got\n \
2993 winner {winner_id} parents={:?}\n loser {loser_id} parents={:?}",
2994 winner_state.parents, loser_state.parents
2995 );
2996 assert!(
2997 winner_state.parents.contains(&initial_head)
2998 || loser_state.parents.contains(&initial_head),
2999 "the bottom of the chain must still reach the seed head"
3000 );
3001 }
3002}