Skip to main content

repo/
repository_tree.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Tree building and materialization helpers.
3
4use std::{
5    collections::{BTreeMap, HashSet},
6    fs,
7    path::Path,
8    time::Instant,
9};
10
11use objects::{
12    object::{Blob, ContentHash, Tree, TreeEntry},
13    store::ObjectStore,
14    util::gitlink_placeholder_bytes,
15    worktree::WorktreeStatus,
16};
17use tracing::{debug, instrument, trace, warn};
18
19use super::{
20    HeddleError, Repository, Result,
21    repository_worktree_status::{WorktreeStatusDetailed, compare_worktree_with_index_detailed},
22};
23use crate::{
24    FsMonitorSettings, WorktreeIndex, WorktreeStatusOptions,
25    fsmonitor::ChangeMonitorSession,
26    thread_manifest::ManifestFile,
27    worktree_ignore::WorktreeIgnoreMatcher,
28    worktree_index::{WorktreeIndexLoadStats, WorktreeIndexSaveStats},
29    worktree_walk::{
30        WalkDirectory, WalkEntry, WorktreeWalkPolicy, cache_key, read_blob_with_hash,
31        validate_symlink_target, walk_worktree,
32    },
33};
34
35#[derive(Debug, Clone, Default)]
36pub struct WorktreeCompareProfile {
37    pub index_load_ms: u128,
38    pub index_snapshot_load_ms: u128,
39    pub index_journal_replay_ms: u128,
40    pub index_snapshot_bytes: u64,
41    pub index_journal_bytes: u64,
42    pub index_journal_ops: usize,
43    pub monitor_prepare_ms: u128,
44    pub compare_ms: u128,
45    pub index_save_ms: u128,
46    pub index_snapshot_write_ms: u128,
47    pub index_journal_append_ms: u128,
48    pub index_save_snapshot_bytes: u64,
49    pub index_save_journal_bytes: u64,
50    pub index_save_journal_ops: usize,
51    pub index_save_compacted: bool,
52    pub monitor_persist_ms: u128,
53    pub untracked_flatten_ms: u128,
54    pub untracked_flattened_paths: usize,
55    pub tracked_refresh_ms: u128,
56    pub untracked_scan_ms: u128,
57    pub hashing_ms: u128,
58    pub directory_cache_compare_ms: u128,
59    pub directories_scanned: u64,
60    pub directories_skipped: u64,
61    pub files_hashed: u64,
62    pub cache_hits: u64,
63    pub monitor_changed_paths: u64,
64    pub monitor_skipped_directories: u64,
65}
66
67#[derive(Debug, Clone, Default)]
68pub struct TreeBuildProfile {
69    pub tree_walk_ms: u128,
70    pub blob_prep_ms: u128,
71    pub blob_write_ms: u128,
72    pub tree_write_ms: u128,
73    pub file_count: usize,
74    pub dir_count: usize,
75}
76
77#[derive(Debug, Clone)]
78struct TreeBuildOutput {
79    tree: Tree,
80    profile: TreeBuildProfile,
81    revalidation_files: BTreeMap<String, ManifestFile>,
82}
83
84impl Repository {
85    /// Build a tree from a directory.
86    #[instrument(skip(self), fields(dir = %dir.display()))]
87    pub fn build_tree(&self, dir: &Path) -> Result<Tree> {
88        self.build_tree_profiled(dir).map(|(tree, _)| tree)
89    }
90
91    /// Build a tree from a directory, reusing per-file hashes from a
92    /// thread manifest when the on-disk `(inode, mtime, ctime, mode)`
93    /// still matches the recorded snapshot.
94    ///
95    /// Same output as [`Self::build_tree`] — a complete `Tree` object —
96    /// but files whose stat fields match the cache skip the
97    /// `read + hash + put_blob` cycle entirely. Net effect on
98    /// `capture_thread_from_disk` for a single-file edit on a 643-file
99    /// fixture: blob work drops from ~30 MB of reads to ~one file's
100    /// worth. Wall-clock follows.
101    ///
102    /// Safe-by-default: any uncertainty (entry missing from cache,
103    /// stat mismatch) falls back to the full read path for that
104    /// specific file. Other files in the same tree still benefit.
105    pub fn build_tree_with_stat_cache(
106        &self,
107        dir: &Path,
108        manifest: &crate::thread_manifest::ThreadManifest,
109    ) -> Result<Tree> {
110        self.build_tree_profiled_inner(dir, None, Some(manifest))
111            .map(|(tree, _)| tree)
112    }
113
114    #[instrument(skip(self), fields(dir = %dir.display()))]
115    pub fn build_tree_profiled(&self, dir: &Path) -> Result<(Tree, TreeBuildProfile)> {
116        self.build_tree_profiled_inner(dir, None, None)
117    }
118
119    pub(crate) fn build_tree_profiled_against(
120        &self,
121        dir: &Path,
122        baseline_tree: Option<&Tree>,
123    ) -> Result<(Tree, TreeBuildProfile)> {
124        self.build_tree_profiled_inner(dir, baseline_tree, None)
125    }
126
127    /// Profiled tree-build that reuses a manifest's stat-cache. Same
128    /// contract as [`Self::build_tree_profiled`] — returns the full
129    /// `(Tree, TreeBuildProfile)` for downstream timing — but skips
130    /// the `read + hash + put_blob` cycle for files whose stat fields
131    /// match the cache. The fall-through path for changed/new files
132    /// is identical, so the resulting tree is byte-identical to what
133    /// the un-cached build would produce.
134    #[instrument(skip(self, manifest), fields(dir = %dir.display()))]
135    pub fn build_tree_profiled_with_stat_cache(
136        &self,
137        dir: &Path,
138        manifest: &crate::thread_manifest::ThreadManifest,
139    ) -> Result<(Tree, TreeBuildProfile)> {
140        self.build_tree_profiled_inner(dir, None, Some(manifest))
141    }
142
143    pub(crate) fn build_tree_profiled_with_stat_cache_against(
144        &self,
145        dir: &Path,
146        baseline_tree: Option<&Tree>,
147        manifest: &crate::thread_manifest::ThreadManifest,
148    ) -> Result<(Tree, TreeBuildProfile)> {
149        self.build_tree_profiled_inner(dir, baseline_tree, Some(manifest))
150    }
151
152    fn build_tree_profiled_inner(
153        &self,
154        dir: &Path,
155        baseline_tree: Option<&Tree>,
156        stat_cache: Option<&crate::thread_manifest::ThreadManifest>,
157    ) -> Result<(Tree, TreeBuildProfile)> {
158        self.build_tree_profiled_output(dir, baseline_tree, stat_cache)
159            .map(|output| (output.tree, output.profile))
160    }
161
162    pub(crate) fn build_tree_profiled_for_snapshot_against(
163        &self,
164        dir: &Path,
165        baseline_tree: Option<&Tree>,
166        stat_cache: Option<&crate::thread_manifest::ThreadManifest>,
167    ) -> Result<(Tree, TreeBuildProfile, BTreeMap<String, ManifestFile>)> {
168        self.build_tree_profiled_output(dir, baseline_tree, stat_cache)
169            .map(|output| (output.tree, output.profile, output.revalidation_files))
170    }
171
172    fn build_tree_profiled_output(
173        &self,
174        dir: &Path,
175        baseline_tree: Option<&Tree>,
176        stat_cache: Option<&crate::thread_manifest::ThreadManifest>,
177    ) -> Result<TreeBuildOutput> {
178        let patterns = self.ignore_patterns()?;
179        debug!(pattern_count = patterns.len(), "Starting tree build");
180        let start = Instant::now();
181        let nested_exclusions = self.nested_thread_worktree_exclusions(dir)?;
182        let tree =
183            self.build_tree_walk(dir, &patterns, nested_exclusions, baseline_tree, stat_cache);
184        let elapsed = start.elapsed().as_millis();
185        debug!(duration_ms = elapsed, "Tree build complete");
186        tree.map(|mut output| {
187            let mut profile = output.profile;
188            profile.tree_walk_ms = elapsed;
189            output.profile = profile;
190            output
191        })
192    }
193
194    #[instrument(skip(self, patterns, nested_exclusions, baseline_tree, stat_cache), fields(dir = %dir.display()))]
195    fn build_tree_walk(
196        &self,
197        dir: &Path,
198        patterns: &[String],
199        nested_exclusions: Vec<std::path::PathBuf>,
200        baseline_tree: Option<&Tree>,
201        stat_cache: Option<&crate::thread_manifest::ThreadManifest>,
202    ) -> Result<TreeBuildOutput> {
203        let ignore_matcher =
204            WorktreeIgnoreMatcher::new(patterns).with_nested_worktree_exclusions(nested_exclusions);
205        let mut policy = TreeBuildPolicy::new(self, dir, stat_cache);
206        let mut output = walk_worktree(self, dir, &ignore_matcher, baseline_tree, &mut policy)?;
207
208        // Flush every newly-seen blob as a single packfile. Stores
209        // that don't override `put_blobs_packed` fall back to per-blob
210        // writes (correct, just slower). Time is folded into
211        // `blob_write_ms` so the existing perf profile keeps tracking
212        // total blob-storage cost.
213        if !policy.pending_blobs.is_empty() {
214            let flush_start = Instant::now();
215            let pending = std::mem::take(&mut policy.pending_blobs);
216            self.store.put_blobs_packed(pending)?;
217            output.profile.blob_write_ms += flush_start.elapsed().as_millis();
218        }
219
220        Ok(output)
221    }
222
223    /// Compare the worktree against a tree using the persisted binary index.
224    pub fn compare_worktree_cached(&self, tree: &Tree) -> Result<WorktreeStatus> {
225        self.compare_worktree_cached_with_options(tree, &self.default_worktree_status_options())
226    }
227
228    pub fn compare_worktree_cached_detailed(&self, tree: &Tree) -> Result<WorktreeStatusDetailed> {
229        self.compare_worktree_cached_detailed_with_options(
230            tree,
231            &self.default_worktree_status_options(),
232        )
233    }
234
235    /// Compare the worktree against a tree using the persisted binary index.
236    pub fn compare_worktree_cached_with_options(
237        &self,
238        tree: &Tree,
239        options: &WorktreeStatusOptions,
240    ) -> Result<WorktreeStatus> {
241        self.compare_worktree_cached_profiled_with_options(tree, options)
242            .map(|(status, _)| status)
243    }
244
245    pub fn compare_worktree_cached_detailed_with_options(
246        &self,
247        tree: &Tree,
248        options: &WorktreeStatusOptions,
249    ) -> Result<WorktreeStatusDetailed> {
250        self.compare_worktree_cached_detailed_profiled_with_options(tree, options)
251            .map(|(status, _)| status)
252    }
253
254    pub fn compare_worktree_cached_profiled_with_options(
255        &self,
256        tree: &Tree,
257        options: &WorktreeStatusOptions,
258    ) -> Result<(WorktreeStatus, WorktreeCompareProfile)> {
259        let (detailed_status, mut profile) =
260            self.compare_worktree_cached_detailed_profiled_with_options(tree, options)?;
261        let flatten_start = Instant::now();
262        let flattened_paths = detailed_status.untracked.flattened_path_count();
263        let mut status = detailed_status.into_flat_status();
264        profile.untracked_flatten_ms = flatten_start.elapsed().as_millis();
265        profile.untracked_flattened_paths = flattened_paths;
266        status.modified.sort();
267        status.added.sort();
268        status.deleted.sort();
269        Ok((status, profile))
270    }
271
272    pub fn compare_worktree_cached_detailed_profiled_with_options(
273        &self,
274        tree: &Tree,
275        options: &WorktreeStatusOptions,
276    ) -> Result<(WorktreeStatusDetailed, WorktreeCompareProfile)> {
277        let index_path = self.worktree_index_path();
278        let load_start = Instant::now();
279        let (mut index, load_stats) = match WorktreeIndex::load_profiled(&index_path) {
280            Ok(result) => result,
281            Err(error) => {
282                warn!(path = %index_path.display(), %error, "Ignoring unreadable worktree index");
283                (WorktreeIndex::new(), WorktreeIndexLoadStats::default())
284            }
285        };
286        let index_load_ms = load_start.elapsed().as_millis();
287
288        let monitor_prepare_start = Instant::now();
289        let monitor = ChangeMonitorSession::prepare(self.root(), options.fsmonitor);
290        let monitor_prepare_ms = monitor_prepare_start.elapsed().as_millis();
291
292        let patterns = self.ignore_patterns()?;
293        let nested_exclusions = self.nested_thread_worktree_exclusions(self.root())?;
294        let ignore_matcher = WorktreeIgnoreMatcher::new(&patterns)
295            .with_nested_worktree_exclusions(nested_exclusions);
296        let compare_start = Instant::now();
297        let (status, stats) = compare_worktree_with_index_detailed(
298            self,
299            tree,
300            &ignore_matcher,
301            &mut index,
302            &monitor,
303        )?;
304        let compare_ms = compare_start.elapsed().as_millis();
305
306        let save_start = Instant::now();
307        let (index_save_ms, save_stats) = if index.is_dirty() {
308            match index.save_profiled(&index_path) {
309                Ok(stats) => {
310                    index.mark_clean();
311                    (save_start.elapsed().as_millis(), stats)
312                }
313                Err(error) => {
314                    warn!(path = %index_path.display(), %error, "Failed to persist worktree index");
315                    (0, WorktreeIndexSaveStats::default())
316                }
317            }
318        } else {
319            (0, WorktreeIndexSaveStats::default())
320        };
321
322        let persist_start = Instant::now();
323        if let Err(error) = monitor.persist() {
324            warn!(path = %self.root().display(), %error, "Failed to persist monitor state");
325        }
326        let monitor_persist_ms = persist_start.elapsed().as_millis();
327
328        debug!(
329            index_load_ms,
330            index_snapshot_load_ms = load_stats.snapshot_load_ms,
331            index_journal_replay_ms = load_stats.journal_replay_ms,
332            index_snapshot_bytes = load_stats.snapshot_bytes,
333            index_journal_bytes = load_stats.journal_bytes,
334            index_journal_ops = load_stats.journal_ops,
335            monitor_prepare_ms,
336            compare_ms,
337            index_save_ms,
338            index_snapshot_write_ms = save_stats.snapshot_write_ms,
339            index_journal_append_ms = save_stats.journal_append_ms,
340            index_save_snapshot_bytes = save_stats.snapshot_bytes,
341            index_save_journal_bytes = save_stats.journal_bytes,
342            index_save_journal_ops = save_stats.journal_ops,
343            index_save_compacted = save_stats.compacted,
344            index_save_compact_reason = save_stats.compact_reason.unwrap_or("none"),
345            monitor_persist_ms,
346            tracked_refresh_ms = stats.tracked_refresh_ms,
347            untracked_scan_ms = stats.untracked_scan_ms,
348            untracked_flatten_ms = 0,
349            untracked_flattened_paths = 0,
350            hashing_ms = stats.hashing_ms,
351            directory_cache_compare_ms = stats.directory_cache_compare_ms,
352            directories_scanned = stats.directories_scanned,
353            directories_skipped = stats.directories_skipped,
354            files_hashed = stats.files_hashed,
355            cache_hits = stats.cache_hits,
356            monitor_backend = monitor.backend.unwrap_or("off"),
357            monitor_status = ?monitor.status,
358            monitor_reason = monitor.reason.as_deref().unwrap_or("ready"),
359            monitor_changed_paths = stats.monitor_changed_paths,
360            monitor_skipped_directories = stats.monitor_skipped_directories,
361            "Worktree compare complete"
362        );
363
364        Ok((
365            status,
366            WorktreeCompareProfile {
367                index_load_ms,
368                index_snapshot_load_ms: load_stats.snapshot_load_ms,
369                index_journal_replay_ms: load_stats.journal_replay_ms,
370                index_snapshot_bytes: load_stats.snapshot_bytes,
371                index_journal_bytes: load_stats.journal_bytes,
372                index_journal_ops: load_stats.journal_ops,
373                monitor_prepare_ms,
374                compare_ms,
375                index_save_ms,
376                index_snapshot_write_ms: save_stats.snapshot_write_ms,
377                index_journal_append_ms: save_stats.journal_append_ms,
378                index_save_snapshot_bytes: save_stats.snapshot_bytes,
379                index_save_journal_bytes: save_stats.journal_bytes,
380                index_save_journal_ops: save_stats.journal_ops,
381                index_save_compacted: save_stats.compacted,
382                monitor_persist_ms,
383                untracked_flatten_ms: 0,
384                untracked_flattened_paths: 0,
385                tracked_refresh_ms: stats.tracked_refresh_ms,
386                untracked_scan_ms: stats.untracked_scan_ms,
387                hashing_ms: stats.hashing_ms,
388                directory_cache_compare_ms: stats.directory_cache_compare_ms,
389                directories_scanned: stats.directories_scanned,
390                directories_skipped: stats.directories_skipped,
391                files_hashed: stats.files_hashed,
392                cache_hits: stats.cache_hits,
393                monitor_changed_paths: stats.monitor_changed_paths,
394                monitor_skipped_directories: stats.monitor_skipped_directories,
395            },
396        ))
397    }
398
399    /// Return whether the worktree matches the provided tree.
400    pub fn worktree_is_clean_cached(&self, tree: &Tree) -> Result<bool> {
401        self.worktree_is_clean_cached_with_options(tree, &self.default_worktree_status_options())
402    }
403
404    /// Return whether the worktree matches the provided tree.
405    pub fn worktree_is_clean_cached_with_options(
406        &self,
407        tree: &Tree,
408        options: &WorktreeStatusOptions,
409    ) -> Result<bool> {
410        Ok(self
411            .compare_worktree_cached_detailed_with_options(tree, options)?
412            .is_clean())
413    }
414
415    fn worktree_index_path(&self) -> std::path::PathBuf {
416        self.root.join(".heddle/state").join("index.bin")
417    }
418
419    fn default_worktree_status_options(&self) -> WorktreeStatusOptions {
420        WorktreeStatusOptions {
421            fsmonitor: FsMonitorSettings::from(self.config.worktree.fsmonitor),
422        }
423    }
424
425    pub fn inspect_change_monitor_with_options(
426        &self,
427        options: &WorktreeStatusOptions,
428    ) -> Result<crate::ChangeMonitorReport> {
429        let session = ChangeMonitorSession::prepare(self.root(), options.fsmonitor);
430        let report = session.report();
431        session.persist()?;
432        Ok(report)
433    }
434}
435
436#[derive(Default)]
437struct TreeBuildState {
438    entries: Vec<TreeEntry>,
439    profile: TreeBuildProfile,
440    revalidation_files: BTreeMap<String, ManifestFile>,
441}
442
443struct TreeBuildPolicy<'a> {
444    repo: &'a Repository,
445    /// Walk root, used to compute paths relative to it so they line
446    /// up with manifest keys (`src/foo.rs`, not absolute paths).
447    walk_root: &'a Path,
448    /// Optional stat-cache. When present, files whose disk stat
449    /// `(inode, mtime, ctime, mode)` matches the recorded entry get
450    /// their hash reused — no `read + hash + put_blob` cycle. Tracked
451    /// in `stat_cache_hits` for diagnostics.
452    stat_cache: Option<&'a crate::thread_manifest::ThreadManifest>,
453    stat_cache_hits: u64,
454    /// Blobs encountered during the walk that aren't already in the
455    /// store. Drained once at the end of the walk into a single
456    /// packfile via `ObjectStore::put_blobs_packed` — turns N×fsync
457    /// per blob into 2×fsync total (the .pack + .idx).
458    pending_blobs: Vec<(ContentHash, Vec<u8>)>,
459    /// Hashes already queued in `pending_blobs` so we don't double-add
460    /// content-equal files (which is common: README.md, .gitkeep, etc).
461    seen: HashSet<ContentHash>,
462}
463
464impl<'a> TreeBuildPolicy<'a> {
465    fn new(
466        repo: &'a Repository,
467        walk_root: &'a Path,
468        stat_cache: Option<&'a crate::thread_manifest::ThreadManifest>,
469    ) -> Self {
470        Self {
471            repo,
472            walk_root,
473            stat_cache,
474            stat_cache_hits: 0,
475            pending_blobs: Vec::new(),
476            seen: HashSet::new(),
477        }
478    }
479
480    /// Look up `entry`'s manifest record by relative path and, if
481    /// found, compare the on-disk `(inode, mtime, ctime, mode)` to
482    /// the recorded snapshot. Returns the cached hash when the
483    /// match is exact; `None` otherwise. The caller falls back to
484    /// the read-and-hash path.
485    fn lookup_stat_cache_hash(&self, entry: &WalkEntry<'_>) -> Option<ContentHash> {
486        let cache = self.stat_cache?;
487        let rel = entry.path.strip_prefix(self.walk_root).ok()?;
488        // Manifest keys use forward-slash separators (cross-platform
489        // by construction; see `populate_manifest_from_tree`).
490        let mut rel_str = String::with_capacity(rel.as_os_str().len());
491        for (i, component) in rel.components().enumerate() {
492            let std::path::Component::Normal(s) = component else {
493                return None;
494            };
495            if i > 0 {
496                rel_str.push('/');
497            }
498            rel_str.push_str(s.to_str()?);
499        }
500        let cached = cache.files.get(&rel_str)?;
501        let (size, inode, mtime_ns, ctime_ns, mode) =
502            crate::stat_signature::stat_signature(entry.path, &entry.metadata);
503        let stat = crate::thread_manifest::ManifestFile {
504            hash: cached.hash,
505            size,
506            inode,
507            mtime_ns,
508            ctime_ns,
509            mode,
510        };
511        if stat.matches(cached) {
512            Some(cached.hash)
513        } else {
514            None
515        }
516    }
517
518    /// Push a blob into the pending pack if it's not already in the
519    /// store and not already queued. The hash is always the canonical
520    /// blob hash — caller passes a precomputed one to avoid hashing
521    /// twice.
522    fn enqueue_blob(&mut self, blob: Blob, hash: ContentHash) -> Result<()> {
523        if self.seen.contains(&hash) {
524            return Ok(());
525        }
526        if self.repo.store.has_blob_locally(&hash)? {
527            self.seen.insert(hash);
528            return Ok(());
529        }
530        self.seen.insert(hash);
531        self.pending_blobs.push((hash, blob.into_content()));
532        Ok(())
533    }
534
535    fn record_revalidation_file(
536        &self,
537        entry: &WalkEntry<'_>,
538        hash: ContentHash,
539        state: &mut TreeBuildState,
540    ) -> Result<()> {
541        let rel = entry.path.strip_prefix(self.walk_root).map_err(|_| {
542            HeddleError::Config(format!(
543                "worktree entry {} escaped snapshot root {}",
544                entry.path.display(),
545                self.walk_root.display()
546            ))
547        })?;
548        let (size, inode, mtime_ns, ctime_ns, mode) =
549            crate::stat_signature::stat_signature(entry.path, &entry.metadata);
550        state.revalidation_files.insert(
551            cache_key(rel),
552            ManifestFile {
553                hash,
554                size,
555                inode,
556                mtime_ns,
557                ctime_ns,
558                mode,
559            },
560        );
561        Ok(())
562    }
563}
564
565impl WorktreeWalkPolicy for TreeBuildPolicy<'_> {
566    type DirectoryState = TreeBuildState;
567    type Output = TreeBuildOutput;
568
569    fn enter_directory(
570        &mut self,
571        _directory: &WalkDirectory<'_>,
572        _tree: Option<&Tree>,
573    ) -> Result<Self::DirectoryState> {
574        Ok(TreeBuildState::default())
575    }
576
577    fn visit_file(
578        &mut self,
579        entry: WalkEntry<'_>,
580        tree_entry: Option<&TreeEntry>,
581        state: &mut Self::DirectoryState,
582    ) -> Result<()> {
583        trace!(file = %entry.path.display(), size = entry.metadata.len(), "Processing file");
584
585        if let Some(target) = tree_entry.and_then(TreeEntry::gitlink_target) {
586            let read_start = Instant::now();
587            let (blob, hash) = read_blob_with_hash(entry.path, entry.metadata.len())?;
588            let read_elapsed = read_start.elapsed().as_millis();
589            if blob.content() == gitlink_placeholder_bytes(&target) {
590                self.record_revalidation_file(&entry, hash, state)?;
591                state.profile.file_count += 1;
592                state.profile.blob_prep_ms += read_elapsed;
593                state
594                    .entries
595                    .push(TreeEntry::gitlink(entry.name.to_string(), target)?);
596                return Ok(());
597            }
598
599            let enqueue_start = Instant::now();
600            self.enqueue_blob(blob, hash)?;
601            let enqueue_elapsed = enqueue_start.elapsed().as_millis();
602            state.profile.file_count += 1;
603            state.profile.blob_prep_ms += read_elapsed;
604            state.profile.blob_write_ms += enqueue_elapsed;
605            self.record_revalidation_file(&entry, hash, state)?;
606            state.entries.push(TreeEntry::file(
607                entry.name.to_string(),
608                hash,
609                entry.executable,
610            )?);
611            return Ok(());
612        }
613
614        // Stat-cache fast path: when this build is on behalf of a
615        // capture against a previously-materialised thread, reuse the
616        // recorded hash if the file's stat fields haven't shifted
617        // since materialise time. Skips the read+hash entirely for
618        // unchanged files — the dominant cost on a "one file edited
619        // in a big repo" capture.
620        if let Some(hash) = self.lookup_stat_cache_hash(&entry)
621            && self.repo.store.has_blob_locally(&hash)?
622        {
623            self.record_revalidation_file(&entry, hash, state)?;
624            self.stat_cache_hits += 1;
625            state.profile.file_count += 1;
626            state.entries.push(TreeEntry::file(
627                entry.name.to_string(),
628                hash,
629                entry.executable,
630            )?);
631            return Ok(());
632        }
633
634        let read_start = Instant::now();
635        let (blob, hash) = read_blob_with_hash(entry.path, entry.metadata.len())?;
636        let read_elapsed = read_start.elapsed().as_millis();
637        trace!(duration_ms = read_elapsed, "File read complete");
638
639        // Defer the actual write — we accumulate every new blob and
640        // install them as a single pack at the end of the walk
641        // (one fsync regardless of file count, vs. ~30ms per loose
642        // file on macOS). The tree entry only needs the hash.
643        let enqueue_start = Instant::now();
644        self.enqueue_blob(blob, hash)?;
645        let enqueue_elapsed = enqueue_start.elapsed().as_millis();
646
647        state.profile.file_count += 1;
648        state.profile.blob_prep_ms += read_elapsed;
649        state.profile.blob_write_ms += enqueue_elapsed;
650        self.record_revalidation_file(&entry, hash, state)?;
651        state.entries.push(TreeEntry::file(
652            entry.name.to_string(),
653            hash,
654            entry.executable,
655        )?);
656        Ok(())
657    }
658
659    fn visit_symlink(
660        &mut self,
661        entry: WalkEntry<'_>,
662        _tree_entry: Option<&TreeEntry>,
663        state: &mut Self::DirectoryState,
664    ) -> Result<()> {
665        let target = fs::read_link(entry.path)?;
666        // Validate symlink escape against the *walk root*, not
667        // `repo.root()`. When `capture_thread_from_disk` builds a
668        // tree from a dedicated thread worktree, the walk root is
669        // the thread's checkout path (not the main repo) and
670        // symlinks should be allowed to point inside it. Pre-fix
671        // every symlink in such a worktree was rejected the moment
672        // the slow path ran, breaking `thread switch` auto-capture
673        // for any thread containing a symlink. For the common case
674        // where `build_tree(self.root)` runs against the main repo
675        // root, `walk_root == self.repo.root()` and behaviour is
676        // unchanged.
677        let symlink_dir = entry.path.parent().unwrap_or(self.walk_root);
678        if !validate_symlink_target(self.walk_root, symlink_dir, &target) {
679            return Err(HeddleError::InvalidSymlinkTarget {
680                path: entry
681                    .path
682                    .strip_prefix(self.walk_root)
683                    .unwrap_or(entry.path)
684                    .to_path_buf(),
685                target,
686            });
687        }
688
689        let blob = Blob::new(objects::util::symlink_target_bytes(&target));
690        let hash = blob.hash();
691        let enqueue_start = Instant::now();
692        self.enqueue_blob(blob, hash)?;
693        state.profile.blob_write_ms += enqueue_start.elapsed().as_millis();
694        self.record_revalidation_file(&entry, hash, state)?;
695        state
696            .entries
697            .push(TreeEntry::symlink(entry.name.to_string(), hash)?);
698        Ok(())
699    }
700
701    fn visit_directory_output(
702        &mut self,
703        entry: WalkEntry<'_>,
704        _tree_entry: Option<&TreeEntry>,
705        subtree: TreeBuildOutput,
706        state: &mut Self::DirectoryState,
707    ) -> Result<()> {
708        trace!(dir = %entry.path.display(), "Processing directory");
709        state.profile.blob_prep_ms += subtree.profile.blob_prep_ms;
710        state.profile.blob_write_ms += subtree.profile.blob_write_ms;
711        state.profile.tree_write_ms += subtree.profile.tree_write_ms;
712        state.profile.file_count += subtree.profile.file_count;
713        state.profile.dir_count += subtree.profile.dir_count + 1;
714        state.revalidation_files.extend(subtree.revalidation_files);
715        let store_start = Instant::now();
716        let hash = self.repo.store.put_tree(&subtree.tree)?;
717        state.profile.tree_write_ms += store_start.elapsed().as_millis();
718        state
719            .entries
720            .push(TreeEntry::directory(entry.name.to_string(), hash)?);
721        Ok(())
722    }
723
724    fn visit_missing(
725        &mut self,
726        _rel_path: &Path,
727        _tree_entry: &TreeEntry,
728        _state: &mut Self::DirectoryState,
729    ) -> Result<()> {
730        Ok(())
731    }
732
733    fn leave_directory(
734        &mut self,
735        directory: &WalkDirectory<'_>,
736        _tree: Option<&Tree>,
737        state: Self::DirectoryState,
738    ) -> Result<TreeBuildOutput> {
739        debug!(
740            dir = %self.repo.root().join(directory.rel_path).display(),
741            files = state.profile.file_count,
742            dirs = state.profile.dir_count,
743            "Directory processed"
744        );
745        Ok(TreeBuildOutput {
746            tree: Tree::from_entries(state.entries),
747            profile: state.profile,
748            revalidation_files: state.revalidation_files,
749        })
750    }
751}
752
753#[cfg(test)]
754mod tests {
755    use objects::object::ContentHash;
756    use tempfile::TempDir;
757
758    use crate::worktree_walk::{read_blob_with_hash, read_file_hash};
759
760    #[test]
761    fn read_blob_with_hash_uses_bytes_read_when_file_grows() {
762        let temp_dir = TempDir::new().unwrap();
763        let path = temp_dir.path().join("file.txt");
764
765        std::fs::write(&path, b"abc").unwrap();
766        let initial_size = std::fs::metadata(&path).unwrap().len();
767        std::fs::write(&path, b"abcdef").unwrap();
768
769        let (blob, hash) = read_blob_with_hash(&path, initial_size).unwrap();
770
771        assert_eq!(blob.content(), b"abcdef");
772        assert_eq!(hash, blob.hash());
773    }
774
775    #[test]
776    fn read_file_hash_uses_bytes_read_when_file_grows() {
777        let temp_dir = TempDir::new().unwrap();
778        let path = temp_dir.path().join("file.txt");
779
780        std::fs::write(&path, b"abc").unwrap();
781        let initial_size = std::fs::metadata(&path).unwrap().len();
782        std::fs::write(&path, b"abcdef").unwrap();
783
784        let hash = read_file_hash(&path, initial_size).unwrap();
785
786        assert_eq!(hash, ContentHash::compute_typed("blob", b"abcdef"));
787    }
788}